fix: resolve CPA auth hardening conflicts with dev

This commit is contained in:
QLHazyCoder
2026-04-29 12:24:15 +08:00
45 changed files with 5804 additions and 1289 deletions
+1979 -474
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -14,6 +14,7 @@
cancelPendingCommands,
clearStopRequest,
createAutoRunSessionId,
ensureHotmailMailboxReadyForAutoRunRound,
getAutoRunStatusPayload,
getErrorMessage,
getFirstUnfinishedStep,
@@ -391,6 +392,7 @@
inbucketMailbox: prevState.inbucketMailbox,
cloudflareDomain: prevState.cloudflareDomain,
cloudflareDomains: prevState.cloudflareDomains,
reusablePhoneActivation: prevState.reusablePhoneActivation,
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
autoRunSessionId: sessionId,
tabRegistry: {},
@@ -439,6 +441,15 @@
sessionId,
});
if (!useExistingProgress && startStep === 1 && typeof ensureHotmailMailboxReadyForAutoRunRound === 'function') {
await ensureHotmailMailboxReadyForAutoRunRound({
targetRun,
totalRuns,
attemptRun,
sessionId,
});
}
await runAutoSequenceFromStep(startStep, {
targetRun,
totalRuns,
+10 -10
View File
@@ -537,6 +537,16 @@
}
}
if (typeof ensureContentScriptReadyOnTab === 'function') {
await ensureContentScriptReadyOnTab(MAIL2925_SOURCE, tabId, {
inject: MAIL2925_INJECT,
injectSource: MAIL2925_INJECT_SOURCE,
timeoutMs: 20000,
retryDelayMs: 800,
logMessage: '步骤 0:2925 登录页内容脚本未就绪,正在等待页面稳定后继续登录...',
});
}
if (!forceRelogin && !isMail2925LoginUrl(openedUrl) && !normalizedExpectedMailboxEmail) {
await addLog('2925:当前邮箱页未跳转到登录页,将直接复用已登录会话。', 'info');
return buildSuccessPayload();
@@ -553,16 +563,6 @@
});
}
if (typeof ensureContentScriptReadyOnTab === 'function') {
await ensureContentScriptReadyOnTab(MAIL2925_SOURCE, tabId, {
inject: MAIL2925_INJECT,
injectSource: MAIL2925_INJECT_SOURCE,
timeoutMs: 20000,
retryDelayMs: 800,
logMessage: '步骤 0:2925 登录页内容脚本未就绪,正在等待页面稳定后继续登录...',
});
}
if (forceRelogin && typeof sleepWithStop === 'function') {
await addLog('2925:登录页已打开,等待 3 秒后开始检查输入框并执行登录...', 'info');
await sleepWithStop(3000);
+17 -15
View File
@@ -32,11 +32,14 @@
executeStepViaCompletionSignal,
exportSettingsBundle,
fetchGeneratedEmail,
finalizePhoneActivationAfterSuccessfulFlow,
finalizeStep3Completion,
finalizeIcloudAliasAfterSuccessfulFlow,
findHotmailAccount,
findPayPalAccount,
flushCommand,
getCurrentLuckmailPurchase,
getCurrentPayPalAccount,
getCurrentMail2925Account,
getPendingAutoRunTimerPlan,
getSourceLabel,
@@ -62,6 +65,7 @@
refreshIpProxyPool,
normalizeHotmailAccounts,
normalizeMail2925Accounts,
normalizePayPalAccounts,
normalizeRunCount,
AUTO_RUN_TIMER_KIND_SCHEDULED_START,
notifyStepComplete,
@@ -79,6 +83,7 @@
selectLuckmailPurchase,
switchIpProxy,
changeIpProxyExit,
setCurrentPayPalAccount,
setCurrentMail2925Account,
setCurrentHotmailAccount,
setContributionMode,
@@ -99,7 +104,9 @@
deleteMail2925Account,
deleteMail2925Accounts,
syncHotmailAccounts,
syncPayPalAccounts,
testHotmailAccountMailAccess,
upsertPayPalAccount,
upsertMail2925Account,
upsertHotmailAccount,
verifyHotmailAccount,
@@ -135,20 +142,6 @@
}
}
<<<<<<< HEAD
function resolveLastStepIdForState(state = {}) {
if (typeof getLastStepIdForState === 'function') {
const fromResolver = Number(getLastStepIdForState(state));
if (Number.isFinite(fromResolver) && fromResolver > 0) {
return fromResolver;
}
}
const stepIds = typeof getStepIdsForState === 'function'
? (getStepIdsForState(state) || [])
: Object.keys(state?.stepStatuses || {}).map((step) => Number(step)).filter(Number.isFinite);
const sorted = stepIds.slice().sort((left, right) => left - right);
return Math.max(10, sorted[sorted.length - 1] || 0);
=======
function getStepKeyForState(step, state = {}) {
if (typeof getStepDefinitionForState === 'function') {
return String(getStepDefinitionForState(step, state)?.key || '').trim();
@@ -220,7 +213,6 @@
if (typeof finalizePhoneActivationAfterSuccessfulFlow === 'function') {
await finalizePhoneActivationAfterSuccessfulFlow(latestState);
}
>>>>>>> 705c749 (fix(flow): restore dynamic router handling and downstream reset safety)
}
async function handleStepData(step, payload) {
@@ -800,6 +792,16 @@
return { ok: true, account };
}
case 'UPSERT_PAYPAL_ACCOUNT': {
const account = await upsertPayPalAccount(message.payload || {});
return { ok: true, account };
}
case 'SELECT_PAYPAL_ACCOUNT': {
const account = await setCurrentPayPalAccount(String(message.payload?.accountId || ''));
return { ok: true, account };
}
case 'DELETE_HOTMAIL_ACCOUNT': {
await deleteHotmailAccount(String(message.payload?.accountId || ''));
return { ok: true };
+110
View File
@@ -0,0 +1,110 @@
(function attachBackgroundPayPalAccountStore(root, factory) {
root.MultiPageBackgroundPayPalAccountStore = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundPayPalAccountStoreModule() {
function createPayPalAccountStore(deps = {}) {
const {
broadcastDataUpdate,
findPayPalAccount,
getState,
normalizePayPalAccount,
normalizePayPalAccounts,
setPersistentSettings,
setState,
upsertPayPalAccountInList,
} = deps;
async function syncSelectedPayPalAccountState(account = null) {
const updates = account
? {
currentPayPalAccountId: account.id,
paypalEmail: String(account.email || '').trim(),
paypalPassword: String(account.password || ''),
}
: {
currentPayPalAccountId: '',
paypalEmail: '',
paypalPassword: '',
};
await setPersistentSettings(updates);
await setState({
currentPayPalAccountId: updates.currentPayPalAccountId || null,
paypalEmail: updates.paypalEmail,
paypalPassword: updates.paypalPassword,
});
broadcastDataUpdate({
currentPayPalAccountId: updates.currentPayPalAccountId || null,
paypalEmail: updates.paypalEmail,
paypalPassword: updates.paypalPassword,
});
}
async function syncPayPalAccounts(accounts) {
const normalized = normalizePayPalAccounts(accounts);
await setPersistentSettings({ paypalAccounts: normalized });
await setState({ paypalAccounts: normalized });
broadcastDataUpdate({ paypalAccounts: normalized });
const state = await getState();
if (state.currentPayPalAccountId && !findPayPalAccount(normalized, state.currentPayPalAccountId)) {
await syncSelectedPayPalAccountState(null);
}
return normalized;
}
function getCurrentPayPalAccount(state = {}) {
return findPayPalAccount(state.paypalAccounts, state.currentPayPalAccountId) || null;
}
async function upsertPayPalAccount(input = {}) {
const state = await getState();
const accounts = normalizePayPalAccounts(state.paypalAccounts);
const normalizedEmail = String(input?.email || '').trim().toLowerCase();
const existing = input?.id
? findPayPalAccount(accounts, input.id)
: accounts.find((account) => account.email === normalizedEmail) || null;
const normalized = normalizePayPalAccount({
...(existing || {}),
...input,
id: input?.id || existing?.id || crypto.randomUUID(),
createdAt: existing?.createdAt || Date.now(),
updatedAt: Date.now(),
});
const nextAccounts = typeof upsertPayPalAccountInList === 'function'
? upsertPayPalAccountInList(accounts, normalized)
: accounts.concat(normalized);
await syncPayPalAccounts(nextAccounts);
if (state.currentPayPalAccountId === normalized.id) {
await syncSelectedPayPalAccountState(normalized);
}
return normalized;
}
async function setCurrentPayPalAccount(accountId) {
const state = await getState();
const accounts = normalizePayPalAccounts(state.paypalAccounts);
const account = findPayPalAccount(accounts, accountId);
if (!account) {
throw new Error('未找到对应的 PayPal 账号。');
}
await syncSelectedPayPalAccountState(account);
return account;
}
return {
getCurrentPayPalAccount,
setCurrentPayPalAccount,
syncPayPalAccounts,
upsertPayPalAccount,
};
}
return {
createPayPalAccountStore,
};
});
+94 -145
View File
@@ -21,13 +21,13 @@
const PHONE_ACTIVATION_STATE_KEY = 'currentPhoneActivation';
const REUSABLE_PHONE_ACTIVATION_STATE_KEY = 'reusablePhoneActivation';
const PENDING_PHONE_ACTIVATION_CONFIRMATION_STATE_KEY = 'pendingPhoneActivationConfirmation';
const DEFAULT_PHONE_POLL_INTERVAL_MS = 5000;
const DEFAULT_PHONE_POLL_TIMEOUT_MS = 180000;
const DEFAULT_PHONE_REQUEST_TIMEOUT_MS = 20000;
const DEFAULT_PHONE_SUBMIT_ATTEMPTS = 3;
const DEFAULT_PHONE_CODE_WAIT_WINDOW_MS = 60000;
const DEFAULT_PHONE_NUMBER_MAX_USES = 3;
const DEFAULT_PHONE_PRICE_LOOKUP_ATTEMPTS = 3;
const PHONE_CODE_TIMEOUT_ERROR_PREFIX = 'PHONE_CODE_TIMEOUT::';
const PHONE_RESTART_STEP7_ERROR_PREFIX = 'PHONE_RESTART_STEP7::';
@@ -47,6 +47,18 @@
return String(value || '').trim();
}
function normalizeManualHeroSmsMaxPrice(value) {
const trimmed = String(value ?? '').trim();
if (!trimmed) {
return null;
}
const price = Number(trimmed);
if (!Number.isFinite(price) || price <= 0) {
return null;
}
return String(price);
}
function normalizeUseCount(value) {
return Math.max(0, Math.floor(Number(value) || 0));
}
@@ -236,13 +248,19 @@
}
}
function resolvePhoneConfig(state = {}) {
function resolvePhoneConfig(state = {}, options = {}) {
const apiKey = normalizeApiKey(state.heroSmsApiKey);
if (!apiKey) {
throw new Error('HeroSMS API key is missing. Save it in the side panel before running the phone flow.');
}
const requireMaxPrice = Boolean(options.requireMaxPrice);
const maxPrice = normalizeManualHeroSmsMaxPrice(state.heroSmsMaxPrice);
if (requireMaxPrice && !maxPrice) {
throw new Error('HeroSMS maxPrice is missing. Fill it in below the country selector before running the phone flow.');
}
return {
apiKey,
...(maxPrice ? { maxPrice } : {}),
baseUrl: normalizeUrl(state.heroSmsBaseUrl, DEFAULT_HERO_SMS_BASE_URL),
};
}
@@ -289,91 +307,10 @@
return activation?.statusAction === 'getStatusV2' ? 'getStatusV2' : 'getStatus';
}
function normalizeHeroSmsPrice(value) {
const price = Number(value);
if (!Number.isFinite(price) || price < 0) {
return null;
}
return price;
}
function collectHeroSmsPriceCandidates(payload, candidates = []) {
if (Array.isArray(payload)) {
payload.forEach((entry) => collectHeroSmsPriceCandidates(entry, candidates));
return candidates;
}
if (!payload || typeof payload !== 'object') {
return candidates;
}
const cost = normalizeHeroSmsPrice(payload.cost);
if (cost !== null) {
const count = Number(payload.count);
const physicalCount = Number(payload.physicalCount);
const hasCount = Number.isFinite(count);
const hasPhysicalCount = Number.isFinite(physicalCount);
if ((!hasCount && !hasPhysicalCount) || count > 0 || physicalCount > 0) {
candidates.push(cost);
}
}
Object.values(payload).forEach((value) => collectHeroSmsPriceCandidates(value, candidates));
return candidates;
}
function findLowestHeroSmsPrice(payload) {
const candidates = collectHeroSmsPriceCandidates(payload, []);
if (!candidates.length) {
return null;
}
return Math.min(...candidates);
}
function isHeroSmsNoNumbersPayload(payload) {
return /\bNO_NUMBERS\b/i.test(describeHeroSmsPayload(payload));
}
function extractHeroSmsWrongMaxPrice(payload) {
if (payload && typeof payload === 'object') {
const title = String(payload.title || '').trim();
const minPrice = normalizeHeroSmsPrice(payload.info?.min);
if (/^WRONG_MAX_PRICE$/i.test(title) && minPrice !== null) {
return minPrice;
}
}
const text = describeHeroSmsPayload(payload);
const match = text.match(/\bWRONG_MAX_PRICE:(\d+(?:\.\d+)?)\b/i);
if (!match) {
return null;
}
return normalizeHeroSmsPrice(match[1]);
}
function isNetworkFetchFailure(error) {
const message = String(error?.message || '').trim();
return /failed to fetch|networkerror|load failed/i.test(message);
}
async function resolveCheapestPhoneActivationPrice(config, countryConfig) {
for (let attempt = 1; attempt <= DEFAULT_PHONE_PRICE_LOOKUP_ATTEMPTS; attempt += 1) {
try {
const payload = await fetchHeroSmsPayload(config, {
action: 'getPrices',
service: HERO_SMS_SERVICE_CODE,
country: countryConfig.id,
}, 'HeroSMS getPrices');
const price = findLowestHeroSmsPrice(payload);
if (price !== null) {
return price;
}
} catch (_) {
// Best-effort lookup only.
}
}
return null;
}
async function fetchPhoneActivationPayload(config, countryConfig, action, options = {}) {
const query = {
action,
@@ -387,49 +324,10 @@
return fetchHeroSmsPayload(config, query, `HeroSMS ${action}`);
}
async function requestPhoneActivationWithPrice(config, countryConfig, action, maxPrice) {
let nextMaxPrice = maxPrice;
let retriedWithUpdatedPrice = false;
let retriedWithoutPrice = false;
while (true) {
try {
return await fetchPhoneActivationPayload(config, countryConfig, action, {
maxPrice: nextMaxPrice,
});
} catch (error) {
const updatedMaxPrice = extractHeroSmsWrongMaxPrice(error?.payload || error?.message);
if (
nextMaxPrice !== null
&& nextMaxPrice !== undefined
&& !retriedWithUpdatedPrice
&& updatedMaxPrice !== null
) {
nextMaxPrice = updatedMaxPrice;
retriedWithUpdatedPrice = true;
continue;
}
if (
nextMaxPrice !== null
&& nextMaxPrice !== undefined
&& !retriedWithoutPrice
&& isNetworkFetchFailure(error)
) {
nextMaxPrice = null;
retriedWithoutPrice = true;
continue;
}
throw error;
}
}
}
async function requestPhoneActivation(state = {}) {
const config = resolvePhoneConfig(state);
const config = resolvePhoneConfig(state, { requireMaxPrice: true });
const countryConfig = resolveCountryConfig(state);
const maxPrice = await resolveCheapestPhoneActivationPrice(config, countryConfig);
const maxPrice = config.maxPrice;
const buildFallbackActivation = (requestAction) => ({
countryId: countryConfig.id,
...(requestAction === 'getNumberV2' ? { statusAction: 'getStatusV2' } : {}),
@@ -438,19 +336,19 @@
let payload;
try {
payload = await requestPhoneActivationWithPrice(config, countryConfig, requestAction, maxPrice);
payload = await fetchPhoneActivationPayload(config, countryConfig, requestAction, { maxPrice });
} catch (error) {
if (!isHeroSmsNoNumbersPayload(error?.payload || error?.message)) {
throw error;
}
requestAction = 'getNumberV2';
payload = await requestPhoneActivationWithPrice(config, countryConfig, requestAction, maxPrice);
payload = await fetchPhoneActivationPayload(config, countryConfig, requestAction, { maxPrice });
}
let activation = parseActivationPayload(payload, buildFallbackActivation(requestAction));
if (!activation && requestAction === 'getNumber' && isHeroSmsNoNumbersPayload(payload)) {
requestAction = 'getNumberV2';
payload = await requestPhoneActivationWithPrice(config, countryConfig, requestAction, maxPrice);
payload = await fetchPhoneActivationPayload(config, countryConfig, requestAction, { maxPrice });
activation = parseActivationPayload(payload, buildFallbackActivation(requestAction));
}
@@ -496,7 +394,7 @@
}
async function completePhoneActivation(state = {}, activation) {
await setPhoneActivationStatus(state, activation, 6, 'HeroSMS setStatus(6)');
await setPhoneActivationStatus(state, activation, 3, 'HeroSMS setStatus(3)');
}
async function cancelPhoneActivation(state = {}, activation) {
@@ -738,6 +636,18 @@
await persistReusableActivation(null);
}
function incrementActivationUseCount(activation) {
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation) {
return null;
}
return {
...normalizedActivation,
successfulUses: Math.min(normalizedActivation.successfulUses + 1, normalizedActivation.maxUses),
};
}
async function acquirePhoneActivation(state = {}) {
const countryConfig = resolveCountryConfig(state);
const reusableActivation = normalizeActivation(state[REUSABLE_PHONE_ACTIVATION_STATE_KEY]);
@@ -769,28 +679,53 @@
return activation;
}
async function markActivationReusableAfterSuccess(activation) {
async function syncReusableActivationAfterUse(activation) {
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation) {
await clearReusableActivation();
return;
}
const successfulUses = normalizedActivation.successfulUses + 1;
if (successfulUses >= normalizedActivation.maxUses) {
if (normalizedActivation.successfulUses >= normalizedActivation.maxUses) {
await clearReusableActivation();
return;
}
await persistReusableActivation({
...normalizedActivation,
successfulUses,
await persistReusableActivation(normalizedActivation);
}
async function persistPendingPhoneActivationConfirmation(activation) {
await setState({
[PENDING_PHONE_ACTIVATION_CONFIRMATION_STATE_KEY]: activation || null,
});
}
async function clearPendingPhoneActivationConfirmation() {
await persistPendingPhoneActivationConfirmation(null);
}
async function finalizePendingPhoneActivationConfirmation(stateOverride = null) {
const state = stateOverride || await getState();
const pendingActivation = normalizeActivation(state[PENDING_PHONE_ACTIVATION_CONFIRMATION_STATE_KEY]);
if (!pendingActivation) {
return null;
}
const committedActivation = incrementActivationUseCount(pendingActivation);
if (!committedActivation) {
await clearPendingPhoneActivationConfirmation();
await clearReusableActivation();
return null;
}
await syncReusableActivationAfterUse(committedActivation);
await clearPendingPhoneActivationConfirmation();
return committedActivation;
}
async function waitForPhoneCodeOrRotateNumber(tabId, state, activation) {
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation) {
let currentActivation = normalizeActivation(activation);
if (!currentActivation) {
throw new Error('Phone activation is missing.');
}
@@ -799,11 +734,11 @@
for (let windowIndex = 1; windowIndex <= 2; windowIndex += 1) {
await addLog(
`Step 9: waiting up to 60 seconds for SMS on ${normalizedActivation.phoneNumber} (${windowIndex}/2).`,
`Step 9: waiting up to 60 seconds for SMS on ${currentActivation.phoneNumber} (${windowIndex}/2).`,
'info'
);
try {
const code = await pollPhoneActivationCode(state, normalizedActivation, {
const code = await pollPhoneActivationCode(state, currentActivation, {
actionLabel: windowIndex === 1
? 'poll phone verification code from HeroSMS'
: 'poll resent phone verification code from HeroSMS',
@@ -820,7 +755,7 @@
lastLoggedStatus = statusText;
lastLoggedPollCount = pollCount;
await addLog(
`Step 9: HeroSMS status for ${normalizedActivation.phoneNumber}: ${statusText} (${Math.ceil(elapsedMs / 1000)}s elapsed).`,
`Step 9: HeroSMS status for ${currentActivation.phoneNumber}: ${statusText} (${Math.ceil(elapsedMs / 1000)}s elapsed).`,
'info'
);
},
@@ -836,10 +771,10 @@
if (windowIndex === 1) {
await addLog(
`Step 9: no SMS arrived for ${normalizedActivation.phoneNumber} within 60 seconds, requesting another SMS.`,
`Step 9: no SMS arrived for ${currentActivation.phoneNumber} within 60 seconds, requesting another SMS.`,
'warn'
);
await requestAdditionalPhoneSms(state, normalizedActivation);
await requestAdditionalPhoneSms(state, currentActivation);
try {
await resendPhoneVerificationCode(tabId);
await addLog('Step 9: clicked "Resend text message" on the phone verification page.', 'info');
@@ -850,10 +785,10 @@
}
await addLog(
`Step 9: still no SMS for ${normalizedActivation.phoneNumber} 60 seconds after resend, restarting from step 7 with a new number.`,
`Step 9: still no SMS for ${currentActivation.phoneNumber} 60 seconds after resend, restarting from step 7 with a new number.`,
'warn'
);
throw buildPhoneRestartStep7Error(normalizedActivation.phoneNumber);
throw buildPhoneRestartStep7Error(currentActivation.phoneNumber);
}
}
@@ -875,6 +810,9 @@
}
if (pageState?.addPhonePage) {
if (normalizeActivation(state[PENDING_PHONE_ACTIVATION_CONFIRMATION_STATE_KEY])) {
await clearPendingPhoneActivationConfirmation();
}
if (activation) {
await cancelPhoneActivation(state, activation);
await clearCurrentActivation();
@@ -965,8 +903,18 @@
continue;
}
await completePhoneActivation(state, activation);
await markActivationReusableAfterSuccess(activation);
try {
await completePhoneActivation(state, activation);
await persistReusableActivation(activation);
await persistPendingPhoneActivationConfirmation(activation);
} catch (activationStatusError) {
await clearReusableActivation();
await clearPendingPhoneActivationConfirmation();
await addLog(
`Step 9: phone verification succeeded, but HeroSMS setStatus(3) failed. The next flow will request a new number. ${activationStatusError.message}`,
'warn'
);
}
shouldCancelActivation = false;
await clearCurrentActivation();
await addLog('Step 9: phone verification finished, waiting for OAuth consent.', 'ok');
@@ -988,6 +936,7 @@
return {
completePhoneVerificationFlow,
finalizePendingPhoneActivationConfirmation,
normalizeActivation,
pollPhoneActivationCode,
reactivatePhoneActivation,
+67 -3
View File
@@ -27,6 +27,7 @@
reuseOrCreateTab,
setState,
shouldUseCustomRegistrationEmail,
sleepWithStop,
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS,
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS,
throwIfStopped,
@@ -167,10 +168,26 @@
});
}
async function runStep8Attempt(state) {
function getStep8ResendIntervalMs(state = {}) {
const mail = getMailConfig(state);
if (mail?.provider === LUCKMAIL_PROVIDER) {
return 15000;
}
if (mail?.provider === HOTMAIL_PROVIDER || mail?.provider === '2925') {
return 0;
}
return Math.max(0, Number(STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS) || 0);
}
async function runStep8Attempt(state, runtime = {}) {
const visibleStep = getVisibleStep(state, 8);
const mail = getMailConfig(state);
if (mail.error) throw new Error(mail.error);
const stateLastResendAt = Number(state?.loginVerificationRequestedAt) || 0;
let latestResendAt = Math.max(0, Number(runtime?.stickyLastResendAt) || 0, stateLastResendAt);
const notifyResendRequestedAt = typeof runtime?.onResendRequestedAt === 'function'
? runtime.onResendRequestedAt
: null;
const stepStartedAt = Date.now();
const verificationFilterAfterTimestamp = mail.provider === '2925'
@@ -267,6 +284,16 @@
disableTimeBudgetCap: mail.provider === '2925',
getRemainingTimeMs: getStep8RemainingTimeResolver(state?.oauthUrl || '', visibleStep),
requestFreshCodeFirst: false,
lastResendAt: latestResendAt,
onResendRequestedAt: async (requestedAt) => {
const numericRequestedAt = Number(requestedAt) || 0;
if (numericRequestedAt > 0) {
latestResendAt = Math.max(latestResendAt, numericRequestedAt);
}
if (notifyResendRequestedAt) {
await notifyResendRequestedAt(latestResendAt);
}
},
targetEmail: fixedTargetEmail,
resendIntervalMs: mail.provider === LUCKMAIL_PROVIDER
? 15000
@@ -274,6 +301,9 @@
? 0
: STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS),
});
return {
lastResendAt: latestResendAt,
};
}
function isStep8RestartStep7Error(error) {
@@ -285,10 +315,22 @@
let currentState = state;
let mailPollingAttempt = 1;
let lastMailPollingError = null;
let stickyLastResendAt = Number(state?.loginVerificationRequestedAt) || 0;
while (true) {
try {
await runStep8Attempt(currentState);
const result = await runStep8Attempt(currentState, {
stickyLastResendAt,
onResendRequestedAt: async (requestedAt) => {
const numericRequestedAt = Number(requestedAt) || 0;
if (numericRequestedAt > 0) {
stickyLastResendAt = Math.max(stickyLastResendAt, numericRequestedAt);
}
},
});
if (Number(result?.lastResendAt) > 0) {
stickyLastResendAt = Math.max(stickyLastResendAt, Number(result.lastResendAt) || 0);
}
return;
} catch (err) {
const visibleStep = getVisibleStep(currentState, 8);
@@ -323,7 +365,29 @@
`步骤 ${visibleStep}:认证页仍保持在验证码页,将在当前链路直接重试(${mailPollingAttempt}/${STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS}),不回到步骤 ${authLoginStep}`,
'warn'
);
currentState = await getState();
const latestState = await getState();
const latestStateResendAt = Number(latestState?.loginVerificationRequestedAt) || 0;
if (latestStateResendAt > 0) {
stickyLastResendAt = Math.max(stickyLastResendAt, latestStateResendAt);
}
currentState = latestState;
if (stickyLastResendAt > 0 && (!latestStateResendAt || latestStateResendAt < stickyLastResendAt)) {
currentState = {
...latestState,
loginVerificationRequestedAt: stickyLastResendAt,
};
}
const resendIntervalMs = getStep8ResendIntervalMs(currentState);
const remainingBeforeRetryMs = stickyLastResendAt > 0 && resendIntervalMs > 0
? Math.max(0, resendIntervalMs - (Date.now() - stickyLastResendAt))
: 0;
if (remainingBeforeRetryMs > 0 && typeof sleepWithStop === 'function') {
await addLog(
`步骤 ${visibleStep}:上轮已触发重发验证码,为避免重复重发,先等待 ${Math.ceil(remainingBeforeRetryMs / 1000)} 秒后继续当前链路重试。`,
'info'
);
await sleepWithStop(Math.min(remainingBeforeRetryMs, 3000));
}
continue;
}
await addLog(
+17 -4
View File
@@ -99,17 +99,30 @@
return result || {};
}
function resolvePayPalCredentials(state = {}) {
const currentId = String(state?.currentPayPalAccountId || '').trim();
const accounts = Array.isArray(state?.paypalAccounts) ? state.paypalAccounts : [];
const selectedAccount = currentId
? accounts.find((account) => String(account?.id || '').trim() === currentId) || null
: null;
return {
email: String(selectedAccount?.email || state?.paypalEmail || '').trim(),
password: String(selectedAccount?.password || state?.paypalPassword || ''),
};
}
async function submitLogin(tabId, state = {}) {
if (!state.paypalPassword) {
throw new Error('步骤 8:未配置 PayPal 密码,请先在侧边栏填写。');
const credentials = resolvePayPalCredentials(state);
if (!credentials.password) {
throw new Error('步骤 8:未配置可用的 PayPal 账号,请先在侧边栏添加并选择账号。');
}
await addLog('步骤 8:正在填写 PayPal 登录信息并提交...', 'info');
const result = await sendTabMessageUntilStopped(tabId, PAYPAL_SOURCE, {
type: 'PAYPAL_SUBMIT_LOGIN',
source: 'background',
payload: {
email: state.paypalEmail || '',
password: state.paypalPassword || '',
email: credentials.email,
password: credentials.password,
},
});
if (result?.error) {
+11 -1
View File
@@ -920,8 +920,18 @@
const maxSubmitAttempts = mail.provider === LUCKMAIL_PROVIDER ? 3 : 15;
const resendIntervalMs = Math.max(0, Number(options.resendIntervalMs) || 0);
let lastResendAt = Number(options.lastResendAt) || 0;
const externalOnResendRequestedAt = typeof options.onResendRequestedAt === 'function'
? options.onResendRequestedAt
: null;
const updateFilterAfterTimestampForVerificationStep = async (_requestedAt) => {
const updateFilterAfterTimestampForVerificationStep = async (requestedAt) => {
if (externalOnResendRequestedAt) {
try {
await externalOnResendRequestedAt(requestedAt);
} catch (_) {
// Keep resend flow best-effort; state sync callback failures should not break verification.
}
}
return nextFilterAfterTimestamp;
};
+50 -2
View File
@@ -699,10 +699,29 @@ function extractEmails(text = '') {
return [...new Set(matches.map((item) => item.toLowerCase()))];
}
function extractForwardedTargetEmails(text = '') {
const normalizedText = String(text || '').toLowerCase();
const matches = normalizedText.match(/bounce\+[a-z0-9._%+-]*-([a-z0-9._%+-]+)=([a-z0-9.-]+\.[a-z]{2,})@(?:tm\d*\.openai\.com|em\d+\.tm\.openai\.com)/gi) || [];
const decoded = matches
.map((candidate) => {
const match = String(candidate || '').match(/bounce\+[a-z0-9._%+-]*-([a-z0-9._%+-]+)=([a-z0-9.-]+\.[a-z]{2,})@/i);
if (!match) {
return '';
}
return `${match[1].toLowerCase()}@${match[2].toLowerCase()}`;
})
.filter(Boolean);
return [...new Set(decoded)];
}
function emailMatchesTarget(candidate, targetEmail) {
const normalizedCandidate = String(candidate || '').trim().toLowerCase();
const normalizedTarget = String(targetEmail || '').trim().toLowerCase();
return Boolean(normalizedCandidate && normalizedTarget && normalizedCandidate === normalizedTarget);
if (!normalizedCandidate || !normalizedTarget) {
return false;
}
return normalizedCandidate === normalizedTarget;
}
function getTargetEmailMatchState(text, targetEmail) {
@@ -717,12 +736,30 @@ function getTargetEmailMatchState(text, targetEmail) {
}
const extractedEmails = extractEmails(normalizedText);
const forwardedTargetEmails = extractForwardedTargetEmails(normalizedText);
if (!extractedEmails.length) {
return forwardedTargetEmails.length
? {
matches: forwardedTargetEmails.some((candidate) => emailMatchesTarget(candidate, normalizedTarget)),
hasExplicitEmail: true,
}
: { matches: true, hasExplicitEmail: false };
}
const targetDomain = normalizedTarget.includes('@')
? normalizedTarget.split('@').pop()
: '';
const comparableEmails = [...new Set(
(targetDomain
? [...extractedEmails, ...forwardedTargetEmails].filter((candidate) => String(candidate || '').trim().toLowerCase().endsWith(`@${targetDomain}`))
: [...extractedEmails, ...forwardedTargetEmails])
)];
if (!comparableEmails.length) {
return { matches: true, hasExplicitEmail: false };
}
return {
matches: extractedEmails.some((candidate) => emailMatchesTarget(candidate, normalizedTarget)),
matches: comparableEmails.some((candidate) => emailMatchesTarget(candidate, normalizedTarget)),
hasExplicitEmail: true,
};
}
@@ -782,6 +819,17 @@ function parseMailItemTimestamp(item) {
return date.getTime();
}
match = timeText.match(/(\d{1,2})月(\d{1,2})日(?:\s*(\d{1,2}):(\d{2}))?/);
if (match) {
date.setMonth(Number(match[1]) - 1, Number(match[2]));
if (match[3] && match[4]) {
date.setHours(Number(match[3]), Number(match[4]), 0, 0);
} else {
date.setHours(0, 0, 0, 0);
}
return date.getTime();
}
match = timeText.match(/(\d{4})-(\d{1,2})-(\d{1,2})\s*(\d{1,2}):(\d{2})/);
if (match) {
return new Date(
+16 -8
View File
@@ -227,6 +227,18 @@ function getPayPalLoginPhase(emailInput, passwordInput) {
return '';
}
function refillPayPalEmailInput(emailInput, email) {
if (!emailInput) return;
if (typeof emailInput.focus === 'function') {
emailInput.focus();
}
fillInput(emailInput, '');
fillInput(emailInput, email);
if (typeof emailInput.blur === 'function') {
emailInput.blur();
}
}
async function submitPayPalLogin(payload = {}) {
await waitForDocumentComplete();
@@ -241,9 +253,7 @@ async function submitPayPalLogin(payload = {}) {
const emailNextButton = findEmailNextButton();
if (emailInput && emailNextButton && isEnabledControl(emailNextButton) && (!passwordInput || !findPasswordLoginButton())) {
if (normalizeText(emailInput.value || '') !== email) {
fillInput(emailInput, email);
}
refillPayPalEmailInput(emailInput, email);
simulateClick(emailNextButton);
return {
submitted: false,
@@ -253,9 +263,7 @@ async function submitPayPalLogin(payload = {}) {
}
if (!passwordInput && emailInput && email) {
if (normalizeText(emailInput.value || '') !== email) {
fillInput(emailInput, email);
}
refillPayPalEmailInput(emailInput, email);
const nextButton = await waitUntil(() => {
const button = findEmailNextButton() || findLoginNextButton();
return button && isEnabledControl(button) ? button : null;
@@ -272,8 +280,8 @@ async function submitPayPalLogin(payload = {}) {
};
} else if (!passwordInput && emailInput && !email) {
throw new Error('PayPal 账号为空,请先在侧边栏配置。');
} else if (emailInput && email && normalizeText(emailInput.value || '') !== email) {
fillInput(emailInput, email);
} else if (emailInput && email) {
refillPayPalEmailInput(emailInput, email);
}
passwordInput = passwordInput || await waitUntil(() => findPasswordInput(), {
+274 -6
View File
@@ -1,6 +1,6 @@
# Codex 注册扩展相关项目、更新、邮箱、PayPal 与 Clash Verge 配置教程
本教程用于说明相关项目地址、扩展更新方式、`Cloudflare Temp Email``iCloud 隐私邮箱``QQ 邮箱` 的使用方法、`PayPal` 注册绑卡流程,以及 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` 配置方法。
本教程用于说明相关项目地址、扩展更新方式、`Cloudflare Temp Email``iCloud 隐私邮箱``QQ 邮箱` 的使用方法、`PayPal` 注册绑卡流程、扩展内置动态 IP 代理、节点检测与纯净度检查,以及 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询` 配置方法。
## 适用场景
@@ -9,7 +9,10 @@
- 需要把 `Cloudflare Temp Email` 用作 `邮箱生成``邮箱服务`
- 需要使用 `iCloud+``隐藏邮件地址` 作为隐私邮箱
- 需要临时切换 `QQ 邮箱` 地址继续使用
- 需要使用 `网易邮箱``网易邮箱大师` 注册多个邮箱或替身邮箱
- 需要注册并使用 `PayPal` 个人账户
- 需要在扩展内使用 `711Proxy` 动态 IP,并在自动流程中按轮次切换出口
- 需要检查当前节点的出口 IP、纯净度、泄露情况和访问速度
- 需要在 `Clash Verge` 中启用 `🔁 非港轮询`
## 准备内容
@@ -21,8 +24,10 @@
- 一个已开通 `iCloud+` 的 Apple ID
- 一个用于接收转发邮件的邮箱
- 一个可正常登录的 `QQ 邮箱`
- 手机端已安装 `网易邮箱``网易邮箱大师`
- 一个可正常接收短信的手机号
- 一张可在线支付的借记卡或信用卡
- 如需使用扩展内置动态 IP,需准备 `711Proxy` 的 Host、Port、代理账号和代理密码
- 如需部署 `cpa`,部署环境必须可以访问 `OpenAI`
- 已安装 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev),并已导入可用订阅
@@ -163,7 +168,7 @@
`iCloud 邮件` 主地址创建说明:[Create a primary email address for iCloud Mail](https://support.apple.com/is-is/guide/icloud/mmdd8d1c5c/icloud)
`隐藏邮件地址` 创建与转发说明:[Create and edit Hide My Email addresses on iCloud.com](https://support.apple.com/lv-lv/guide/icloud/mm1a876f7aed/icloud)
### 第五部分:`QQ 邮箱`切换邮箱使用教程
### 第五部分:邮箱方法一:`QQ 邮箱`切换邮箱使用教程
1. 登录 `QQ 邮箱`
先登录你当前正在使用的 `QQ 邮箱` 账号。
@@ -182,7 +187,41 @@
这两个邮箱地址使用完成后,可以直接删除。
删除后再次创建新的英文邮箱和 `Foxmail` 邮箱,即可重复注册使用。
### 第六部分:`PayPal` 注册与绑卡使用教程
### 第六部分:邮箱方法二:`网易邮箱` 注册与替身邮箱使用教程
1. 安装手机端 App
在手机上下载安装 `网易邮箱``网易邮箱大师`
打开后,先使用一个手机号登录。
2. 进入注册邮箱入口
点击右下角的 `我`
找到并点击 `注册邮箱`
3. 选择 `网易免费邮箱`
在注册邮箱页面中选择 `网易免费邮箱`
进入后,可以使用同一个手机号分别注册 `163``126``yeah` 邮箱。
4. 控制注册节奏
同一个手机号理论上可以注册多个网易邮箱账号。
`163``126``yeah` 加起来大约可以注册 `15` 个。
不要短时间连续注册,连续注册太快容易触发频繁提示或限制。
更稳妥的做法是注册一个后暂停一会儿,或者换一个网络环境再继续。
5. 添加 `替身邮箱`
注册完成后,回到 `注册邮箱` 入口附近。
点击旁边的 `替身邮箱`
进入后,每个网易邮箱账号可以添加 `2` 个替身邮箱。
6. 计算可用邮箱数量
如果一个手机号注册了约 `15` 个网易邮箱账号,每个账号再添加 `2` 个替身邮箱,那么总共大约可以得到 `45` 个可用邮箱地址。
这些邮箱地址后续可以用于其他需要独立邮箱的注册或接收邮件场景。
7. 注意域名和注册环境
`163``126``yeah` 这类网易邮箱域名通常比较常见。
只要网络环境不要太差,并且不要连续高频注册,一般不容易马上触发额外手机号验证。
建议注册一个后换节奏再继续,不要一口气连续创建。
### 第七部分:`PayPal` 注册与绑卡使用教程
1. 打开注册页面
打开 [https://www.paypal.com/signin](https://www.paypal.com/signin)。
@@ -234,14 +273,14 @@
常见情况是上传身份证件。
`PayPal` 官方帮助中心说明,通常会在 `2 个工作日` 内审核,但某些情况可能更久。
### 第部分:0元试用 ChatGPT Plus 教程
### 第部分:0元试用 ChatGPT Plus 教程
本部分说明如何在已登录 ChatGPT 的状态下,通过脚本快速生成 Plus 支付链接,然后选择 PayPal 支付完成0元试用订阅。
#### 准备工作
1. 已有一个登录状态的 ChatGPT 账户。
2. 一个可用的 PayPal 账户(参考第部分进行注册和绑卡)。
2. 一个可用的 PayPal 账户(参考第部分进行注册和绑卡)。
3. 能够接收生成的账单地址的真实地址或虚拟地址。
4. Chrome 浏览器(推荐使用地址补全功能)。
@@ -389,7 +428,147 @@
- **PayPal 登录后页面无法继续跳转**
稍等片刻让页面加载完毕。如果长时间未响应,检查浏览器是否有弹窗被隐藏,或尝试刷新页面。
### 第部分:配置 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询`
### 第部分:扩展内置动态 IP 代理使用教程
本部分说明扩展侧边栏里的 `IP代理` 功能。
它和 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 这类系统代理不一样:扩展会通过浏览器代理 API 给当前浏览器设置 PAC 代理,并自动回填代理鉴权。当前首版只开放 `711Proxy` 的账号密码模式,`API 拉取` 和多账号列表入口暂未开放。
#### 一、什么时候需要打开 `IP代理`
如果你已经在 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 或其他客户端里稳定使用节点,可以继续使用外部代理。
如果你希望在扩展里直接控制动态 IP、检查当前出口、按成功轮次自动切换,或者不想依赖系统全局代理,就使用这里的 `IP代理`
开启 `IP代理` 后,扩展会接管浏览器代理。
关闭 `IP代理` 后,浏览器会恢复默认代理或外部全局代理。
#### 二、注册或登录 `711Proxy`
在侧边栏打开 `IP代理` 后,`代理服务` 当前固定为 `711Proxy(首版)`
点击旁边的 `注册` 按钮,按页面提示完成注册或登录。
在网站中完成注册、登录和套餐准备后,进入代理信息页面,找到可用于账号密码代理的 `Host``Port``Username``Password`
#### 三、填写固定账号代理
1. 在扩展侧边栏打开 `IP代理` 开关。
2. `代理模式` 保持 `账号密码`
3. 填写 `代理 Host`,例如 `global.rotgb.711proxy.com`
4. 填写 `代理 Port`,例如 `10000`
5. `代理协议` 一般保持 `HTTP`,除非你的代理信息明确要求 `HTTPS``SOCKS5``SOCKS4`
6. 填写 `代理账号``代理密码`
7. 按需填写 `地区参数`,例如 `US``DE``HK`
8. 按需填写 `会话(session)``时长(life)`
`地区参数` 只接受两个字母的国家或地区代码。
`会话(session)` 会被追加或写回到用户名里的 `session-xxxx`
`时长(life)` 会被追加或写回到用户名里的 `sessTime-xx`711Proxy 的有效范围按 `5-180` 分钟使用更稳。
如果你的 `711Proxy` 用户名里已经带了 `region-US``session-xxxx``sessTime-30`,扩展会优先识别用户名里的值。
如果你在表单里修改地区、session 或时长,扩展会把它们同步回最终生效的代理用户名。
#### 四、同步、下一条、Change 和检测出口
填写或修改代理信息后,不要只保存后就直接开跑。建议按下面顺序操作:
1. 点击 `同步`
扩展会保存当前配置,生成代理规则,应用到浏览器,并自动做一次出口检测。
这是修改 Host、Port、账号、密码、地区、session 或时长后最常用的按钮。
2. 查看 `代理状态`
状态卡会显示当前代理、当前出口 IP、出口地区和诊断详情。
如果显示 `当前代理``当前出口`,说明扩展已经能看到代理出口。
3. 点击 `检测出口`
它只重新检测当前出口,不主动更换节点。
如果你怀疑检测结果没刷新,或者刚刚切换过外部网络,可以点它复测。
4. 点击 `检查IP`
它会打开 `https://ipinfo.io/what-is-my-ip`,用于人工确认网页看到的公网 IP。
5. 点击 `下一条`
当前首版主要是固定账号模式。如果只有一条可用代理,`下一条` 会重绑当前节点并复测,不保证一定换出口。
如果后续开放多账号列表或 API 池,`下一条` 才会真正切到池里的下一条。
6. 点击 `Change`
`Change` 只支持 `711Proxy + 账号密码模式 + 用户名包含 session` 的场景。
它会保持当前 session,强制重绑代理链路并刷新出口,适合想换出口但不想改整条账号配置时使用。
#### 五、任务切换阈值
`任务切换阈值` 表示自动流程成功多少轮后尝试自动切换一次代理。
默认是 `20` 轮,可填写 `1-500`
需要注意:
- 当前只有一条固定账号时,即使命中阈值,也会跳过自动切换。
- 如果后续有多条可切换代理,命中阈值后会自动执行 `下一条`
- 这个阈值只影响自动流程成功后的切换节奏,不会替代你手动点击 `同步``检测出口`
#### 六、状态卡怎么看
常见状态含义如下:
- `未开启`:扩展没有接管浏览器代理,继续使用默认网络或外部全局代理。
- `当前代理:host:port;当前出口:x.x.x.x`:代理已经应用,并检测到出口 IP。
- `已启用,但账号模式没有可用代理`:Host/Port 为空或格式不完整,需要先补齐代理信息。
- `连通性失败`:代理应用了,但没有检测到可用出口,优先检查 Host、Port、协议、账号和密码。
- `地区校验未通过`:检测到的出口地区和你填写的地区参数不一致。711Proxy 场景下扩展会保留接管并给出警告,你需要决定是否继续使用这个出口。
- `当前链路未收到 407 代理鉴权挑战`:代理服务没有按浏览器扩展代理 API 预期返回标准鉴权挑战,可能导致账号密码无法自动回填。可以尝试 `Change`、关闭再开启 `IP代理`、换 session,或者更换代理节点。
如果代理不可用,扩展会启用目标站点阻断保护,避免在代理失效时继续直连访问目标站点。修好代理后重新点击 `同步``检测出口` 即可恢复。
#### 七、推荐使用顺序
首次配置建议按这个顺序:
1. 点击 `注册` 打开 `711Proxy`,准备代理账号信息。
2. 打开扩展侧边栏的 `IP代理`
3. 填写 Host、Port、协议、账号、密码。
4. 填写地区参数,例如 `US`
5. 填写 session 和时长,例如 session 为随机前缀、时长为 `30`
6. 点击 `同步`
7. 确认 `代理状态` 里有出口 IP。
8. 点击 `检查IP`,人工确认网页检测结果。
9. 再继续执行注册、登录或自动流程。
如果后续要换出口,优先尝试 `Change`
如果 `Change` 不可用,再调整 session 或代理账号后点击 `同步`
### 第十部分:节点检测与纯净度检查网站
本部分用于检查当前代理节点的出口 IP、归属地、风险标签、泄露情况和网站访问速度。
建议每次切换节点后都重新打开这些网站检查一次。
1. 使用 [IP111](https://ip111.cn/) 检查出口 IP
`IP111` 会展示当前访问网站时识别到的 IP 信息。
它适合用来快速确认当前节点是否已经生效,以及国内外访问看到的出口 IP 是否一致。
如果你切换节点后页面仍显示原来的 IP,说明代理可能没有切换成功,或者浏览器还有缓存/分流规则没有生效。
2. 使用 [IPData](https://ipdata.co/) 查看 IP 资料和风险信息
`IPData` 偏向 IP 数据查询。
它可以查看 IP 的国家、地区、运营商、组织、ASN、时区、货币等基础信息。
它也会展示威胁和风险相关信息,例如是否像代理、VPN、Tor、数据中心 IP、匿名 IP,或者是否存在滥用记录。
适合用来判断节点的基础身份和风险标签。
3. 使用 [IPPure](https://ippure.com/) 检查 IP 纯净度
`IPPure` 更偏向一键检测 IP 纯净度。
它会显示 IP 位置、风险检测、代理/VPN/黑名单等信息。
页面中还包含浏览器指纹、出口地图、`WebRTC` 泄露、`DNS` 泄露等检测项。
适合用来判断节点是否干净,以及浏览器是否暴露了真实网络环境。
4. 使用 [TCPTest 网站测速](https://www.tcptest.cn/?cckey=e62f31db) 检查访问速度
`TCPTest` 主要用于网站测速和连通性检测。
它可以测试 `Ping``TCPing``HTTP` 速度、`DNS` 解析、路由追踪和 `MTR`
如果某个网站访问慢、打不开,或者想比较不同节点访问同一网站的速度,可以用它测试连接耗时、状态码、解析耗时和线路表现。
这类测速结果不等于 IP 纯净度,但可以帮助判断节点访问目标网站是否稳定。
5. 推荐检查顺序
先打开 [IP111](https://ip111.cn/) 确认出口 IP 是否切换成功。
再打开 [IPData](https://ipdata.co/) 查看 IP 归属、ASN 和风险标签。
接着打开 [IPPure](https://ippure.com/) 检查纯净度、黑名单、代理识别和泄露情况。
最后用 [TCPTest 网站测速](https://www.tcptest.cn/?cckey=e62f31db) 测试目标网站的访问速度和连通性。
### 第十一部分:配置 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 的 `🔁 非港轮询`
#### 第一步:添加扩展脚本
@@ -497,6 +676,48 @@ function main(config, profileName) {
4. 确认右上角的 `代理模式``Mode`)已经设置为 `规则模式``Rule`)。
5. 确认左侧 `设置` 中的 `系统代理``System Proxy`)已经开启。
![2026 04 25 003703](https://apikey.qzz.io/content-assets/library/2026/04/20260424-163745--2026-04-25-003703--21e892504b82.png)
### 第十二部分:订阅节点与自建推荐
如果你还没有订阅节点或想要寻找稳定、便宜的科学上网方式,可以参考以下三种方案获取。
#### 方法一:获取日常免费节点(85LA)
1. 打开 [85LA 科学上网免费节点](https://www.85la.com/internet-access)。
2. 网站每天会更新包含数百个测试通过的高速节点订阅,支持 `v2ray``Clash``SING-BOX` 等主流客户端。
3. 复制最新日期的订阅链接,直接导入你的客户端即可免费使用。
#### 方法二:选择限时免费与高性价比机场推荐(二毛博客)
1. 打开 [2026年翻墙机场推荐评测](https://www.ermao.net/posts/vpn/)。
2. 该页面整理了数十家机场的短期与长期流量包价格,其中像 `ssone``flybit` 等很多机场在注册后均赠送限时免费试用(如送 1 天 1G/2G 流量等)。
3. 可以根据网页中的评测挑选符合你需求与预算的低价机场来保障长期稳定。
#### 方法三:十分钟自建 Cloudflare 永久免费节点
如果你不想花钱,且希望获得一个长期稳定的私人免费节点,可以利用 `Cloudflare Pages` 快速搭建。
1. **第一步:注册免费域名**
访问诸如 [DNS.HE](https://www.dnshe.com/) 或 [DigitalPlat](https://digitalplat.org/) 注册,并获取一个永久免费的域名(例如结尾是 `ccwu.cc``us.ci` 的域名)。
2. **第二步:托管域名至 Cloudflare**
登录 [Cloudflare 后台](https://www.cloudflare.com/),将刚刚申请好的免费域名添加进去进行托管,并根据提示修改域名的 NS 记录直到激活显示。
3. **第三步:创建 KV 命名空间**
在 Cloudflare 后台侧边栏找到 `存储和数据库` -> `Worker KV`,点击 `创建命名空间`,可以随意命名(主要是为了稍后绑定缓存数据)。
4. **第四步:部署 Pages 节点服务**
- 展开侧边栏 `Workers 和 Pages`,点击 `创建`,然后选择 `Pages` 标签页中的 `上传资产`
- 下载由 `cmliu` 开源的 [EdgeTunnel 资源库](https://github.com/cmliu/edgetunnel/archive/refs/heads/main.zip) 原程序压缩包(`main.zip`)。
-`main.zip` 上传并部署。
- 部署完成后点击 `继续处理站点`,进入项目的 `设置` -> `环境变量` -> `添加变量`,变量名填入 `ADMIN`,值设为你想要的后台登录管理员密码,点击 `保存`
- 在同一个 `设置` 页中选择 `绑定` -> `添加` -> `KV 命名空间`,变量名称统一填写 `KV`,并选中你在第三步创建好的那个命名空间进行保存。
- 返回 `部署` 选项卡重新上传 `main.zip` 进行覆盖部署即可使得配置生效。
- 返回项目的 `自定义域` 标签卡,添加一条你的免费域名(如 `node.ccwu.cc`),并按要求去 DNS 面板配置 CNAME 指向分配的 `pages.dev` 后激活。
5. **第五步:获取订阅与后台管理**
直接在浏览器访问你的自定义域名并加上后台路径(如 `https://node.ccwu.cc/admin`),通过第四步设置的密码登录后台。在后台即可复制你的专属 VLESS / Trojan 节点订阅地址,也可导入到各路客户端中直接使用。
## 常见问题
### 为什么第十步显示认证成功,但没有认证文件?
@@ -511,6 +732,16 @@ function main(config, profileName) {
可以。你在 `账号管理` 中创建英文邮箱和 `Foxmail` 邮箱,使用后直接删除,再重复创建即可继续使用。
### `网易邮箱` 一个手机号大约可以注册多少个邮箱?
按目前使用经验,同一个手机号可以分别注册 `163``126``yeah` 邮箱,加起来大约 `15` 个账号。
每个账号还可以添加 `2``替身邮箱`,所以总共大约可以得到 `45` 个可用邮箱地址。
### `网易邮箱` 注册时提示频繁怎么办?
先停止连续注册,隔一段时间再继续。
短时间连续注册容易触发频繁提示或限制,建议注册一个后暂停一会儿,或者换一个网络环境再继续。
### `iCloud 隐私邮箱` 插件刷新后没有邮箱怎么办?
先确认你已经在网页中登录 `iCloud`,并且已经在 `隐藏邮件地址` 里手动创建过隐私邮箱。
@@ -538,6 +769,35 @@ function main(config, profileName) {
请先确认脚本已经完整粘贴并保存,然后回到 `代理` 页面重新查看。若仍未出现,请继续确认当前订阅可用、`代理模式``规则模式`,并且 `系统代理` 已开启。
### 扩展 `IP代理` 开启后,为什么状态还是没有出口 IP?
先确认 Host、Port、协议、代理账号和代理密码都已填写正确。
修改这些字段后,需要点击 `同步` 才会重新应用代理并检测出口。
如果诊断提示没有收到 `407` 代理鉴权挑战,可以尝试点击 `Change`、关闭再开启 `IP代理`、更换 session,或者换一条代理节点。
### `Change` 按钮为什么点不了?
`Change` 只在 `711Proxy` 的账号密码模式下可用,并且当前生效用户名必须包含 `session-xxxx` 这类 session 参数。
如果没有 session,请先在 `会话(session)` 中填写一个随机前缀,再点击 `同步`
### `IP代理` 和 Clash Verge 应该同时开吗?
通常二选一即可。
如果开启扩展内置 `IP代理`,浏览器会优先使用扩展设置的 PAC 代理;如果关闭它,浏览器会回到默认网络或外部全局代理。
排查问题时建议先只保留一种代理方式,确认出口 IP 稳定后再继续流程。
### 网站测速结果好,是否代表节点纯净?
不代表。
`TCPTest` 主要看连接速度、解析耗时、状态码和线路稳定性。
节点纯净度还需要结合 [IPData](https://ipdata.co/) 和 [IPPure](https://ippure.com/) 的风险标签、代理识别、黑名单、`DNS` 泄露和 `WebRTC` 泄露结果一起判断。
### 切换节点后为什么检测网站结果没变?
先确认 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 已经切换到目标节点或目标策略组。
然后刷新检测网站,必要时关闭浏览器重新打开。
如果 [IP111](https://ip111.cn/) 仍显示旧 IP,说明当前浏览器流量可能没有走新节点,或者分流规则没有命中。
## 注意事项
- 不论使用哪种方式更新扩展,更新完成后都必须在浏览器的 `扩展程序管理` 页面重新加载一次该扩展。
@@ -548,9 +808,17 @@ function main(config, profileName) {
- 如果条件允许,建议使用非日常主力 Apple ID 配置 `iCloud 隐私邮箱`,避免影响平时常用账号。
- 如需部署 `cpa`,请先确认部署环境可以访问 `OpenAI`,否则可能出现第十步认证成功但没有认证文件的问题。
- `QQ 邮箱`切换邮箱时,建议在使用完成后及时删除,再重新创建,避免混淆当前正在使用的邮箱地址。
- `网易邮箱` 注册时不要短时间连续创建多个账号,否则容易触发频繁提示或限制。
- `网易邮箱``替身邮箱` 是在单个邮箱账号下添加的,注册完成主邮箱后再进入 `替身邮箱` 页面添加。
- 中国大陆居民注册 `PayPal` 个人账户时,姓名请按身份证上的中文姓名填写,不要使用拼音。
- `PayPal` 官方说明,绑定新卡时可能会向发卡行发送最高 `1 USD` 或等值货币的临时授权验证,所以完全没有余额的卡也可能因为授权失败而无法绑定。
- 为了控制风险,更稳妥的做法是使用单独管理、余额较低的借记卡,并在绑卡后及时检查 `钱包` 页面和右上角通知。
- 使用扩展内置 `IP代理` 时,修改 Host、Port、账号、密码、地区、session 或时长后,请点击 `同步` 让新配置真正生效。
- `711Proxy``Change` 需要用户名中包含 session;没有 session 时请先填写 `会话(session)` 并同步。
- 扩展 `IP代理` 报连通性失败时,不要直接继续自动流程,先用 `检测出口``检查IP` 确认网页看到的出口 IP。
- 检查节点时,先确认出口 IP,再看风险标签和泄露检测,最后再看网站测速。
- [TCPTest 网站测速](https://www.tcptest.cn/?cckey=e62f31db) 主要用于判断访问速度和连通性,不要单独用它判断节点纯净度。
- [IPPure](https://ippure.com/) 如果提示 `WebRTC``DNS` 泄露,请优先检查浏览器和代理客户端的相关设置。
- 使用 `git pull` 更新扩展是最方便、最推荐的方式。
-`Clash Verge` 中粘贴脚本时,请先清空旧内容,再完整粘贴新代码并保存。
+2 -2
View File
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "codex-oauth-automation-extension",
"version": "1.4",
"version_name": "Ultra1.4",
"version": "2.0",
"version_name": "Ultra2.0",
"description": "用于自动执行多步骤 OAuth 注册流程",
"permissions": [
"sidePanel",
+56
View File
@@ -0,0 +1,56 @@
(function attachPayPalUtils(root, factory) {
root.PayPalUtils = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createPayPalUtils() {
function normalizePayPalAccount(account = {}) {
const normalizedEmail = String(account.email || '').trim().toLowerCase();
const now = Date.now();
return {
id: String(account.id || crypto.randomUUID()),
email: normalizedEmail,
password: String(account.password || ''),
createdAt: Number.isFinite(Number(account.createdAt)) ? Number(account.createdAt) : now,
updatedAt: Number.isFinite(Number(account.updatedAt)) ? Number(account.updatedAt) : now,
lastUsedAt: Number.isFinite(Number(account.lastUsedAt)) ? Number(account.lastUsedAt) : 0,
};
}
function normalizePayPalAccounts(accounts) {
if (!Array.isArray(accounts)) return [];
const deduped = new Map();
for (const account of accounts) {
const normalized = normalizePayPalAccount(account);
if (!normalized.email) continue;
deduped.set(normalized.id, normalized);
}
return [...deduped.values()];
}
function findPayPalAccount(accounts = [], accountId = '') {
const normalizedId = String(accountId || '').trim();
if (!normalizedId) return null;
return normalizePayPalAccounts(accounts).find((account) => account.id === normalizedId) || null;
}
function upsertPayPalAccountInList(accounts = [], nextAccount = null) {
if (!nextAccount) {
return normalizePayPalAccounts(accounts);
}
const normalizedNext = normalizePayPalAccount(nextAccount);
const list = normalizePayPalAccounts(accounts);
const existingIndex = list.findIndex((account) => account.id === normalizedNext.id);
if (existingIndex >= 0) {
list[existingIndex] = normalizedNext;
return list;
}
return [...list, normalizedNext];
}
return {
findPayPalAccount,
normalizePayPalAccount,
normalizePayPalAccounts,
upsertPayPalAccountInList,
};
});
+107 -14
View File
@@ -76,8 +76,11 @@ def json_response(handler, status, payload):
handler.send_header("Access-Control-Allow-Origin", "*")
handler.send_header("Access-Control-Allow-Headers", "Content-Type")
handler.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
handler.end_headers()
handler.wfile.write(body)
try:
handler.end_headers()
handler.wfile.write(body)
except (BrokenPipeError, ConnectionResetError) as exc:
log_info(f"response aborted by client status={status} detail={compact_text(exc)}")
def read_json_payload(handler):
@@ -120,6 +123,87 @@ def log_info(message):
print(f"[HotmailHelper] {message}", flush=True)
def is_openai_sender(address):
sender = str(address or "").strip().lower()
return "openai" in sender
def get_message_body_content(message):
body = message.get("body") or {}
if not isinstance(body, dict):
return ""
return str(body.get("content") or "").strip()
def log_openai_messages(messages, transport=""):
for message in messages or []:
sender_info = message.get("from", {}).get("emailAddress", {}) or {}
sender = str(sender_info.get("address") or "").strip()
if not is_openai_sender(sender):
continue
sender_name = str(sender_info.get("name") or "").strip()
mailbox = str(message.get("mailbox") or "").strip() or "INBOX"
subject = str(message.get("subject") or "").strip()
transport_label = str(transport or "").strip() or "unknown"
base = (
f"transport={transport_label} mailbox={mailbox} sender={sender} "
f"senderName={sender_name or '-'} subject={subject}"
)
log_info(f"openai mail received {base}")
body_content = get_message_body_content(message)
if body_content:
log_info(f"openai mail full body start {base}")
print(body_content, flush=True)
log_info("openai mail full body end")
continue
preview = str(message.get("bodyPreview") or "").strip()
if preview:
log_info(f"openai mail preview {base} preview={compact_text(preview, 1000)}")
def get_proxy_debug_context():
names = ["all_proxy", "http_proxy", "https_proxy", "ALL_PROXY", "HTTP_PROXY", "HTTPS_PROXY"]
parts = []
for name in names:
value = str(os.environ.get(name) or "").strip()
if value:
parts.append(f"{name}={value}")
return ",".join(parts) if parts else "direct"
def classify_token_refresh_failure(result):
detail = str(result.get("error") or "").strip().lower()
if "invalid_grant" in detail or "aadsts70000" in detail:
return "invalid_grant"
if "proxy authentication required" in detail:
return "proxy_auth_failed"
if "connection refused" in detail:
return "proxy_connect_failed" if get_proxy_debug_context() != "direct" else "connection_refused"
if "eof occurred in violation of protocol" in detail or "wrong version number" in detail:
return "proxy_tls_failed" if get_proxy_debug_context() != "direct" else "tls_failed"
if "timed out" in detail or "timeout" in detail:
return "network_timeout"
return "request_failed"
def log_token_refresh_failure_diagnosis(result):
category = classify_token_refresh_failure(result)
message = (
"token refresh diagnosis "
f"endpoint={result['endpoint']} "
f"category={category}"
)
if category.startswith("proxy_"):
message += f" proxy={get_proxy_debug_context()}"
elif category == "invalid_grant":
message += " hint=refresh_token_or_scope_invalid"
log_info(message)
def append_account_log(email_addr, password, status, recorded_at="", reason=""):
normalized_email = str(email_addr or "").strip()
normalized_password = str(password or "").strip()
@@ -320,6 +404,7 @@ def refresh_access_token(client_id, refresh_token, strategy_names=None):
f"elapsedMs={result['elapsed_ms']} "
f"detail={result['error']}"
)
log_token_refresh_failure_diagnosis(result)
details = " | ".join(
f"{item['endpoint']}({item['status']}): {item['error']}"
@@ -438,6 +523,9 @@ def normalize_message(message_id, raw_bytes, mailbox):
}
},
"bodyPreview": body[:500],
"body": {
"content": body,
},
"receivedDateTime": to_iso_string(timestamp_ms),
"receivedTimestamp": timestamp_ms,
}
@@ -532,12 +620,12 @@ def normalize_outlook_message(message, mailbox):
def fetch_graph_messages(access_token, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT):
mailbox_id = normalize_mailbox_id(mailbox)
url = (
f"{GRAPH_API_ORIGIN}/v1.0/me/mailFolders/{mailbox_id}/messages"
f"?$top={max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30))}"
f"&$select=id,internetMessageId,subject,from,bodyPreview,receivedDateTime"
f"&$orderby=receivedDateTime desc"
)
query = urlencode({
"$top": max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30)),
"$select": "id,internetMessageId,subject,from,bodyPreview,receivedDateTime",
"$orderby": "receivedDateTime desc",
})
url = f"{GRAPH_API_ORIGIN}/v1.0/me/mailFolders/{mailbox_id}/messages?{query}"
try:
_, payload = get_json(url, headers={
"Accept": "application/json",
@@ -555,12 +643,12 @@ def fetch_graph_messages(access_token, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT)
def fetch_outlook_api_messages(access_token, mailbox="INBOX", top=FETCH_LIMIT_DEFAULT):
mailbox_id = normalize_mailbox_id(mailbox)
url = (
f"{OUTLOOK_API_ORIGIN}/api/v2.0/me/mailfolders/{mailbox_id}/messages"
f"?$top={max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30))}"
f"&$select=Id,Subject,From,BodyPreview,ReceivedDateTime"
f"&$orderby=ReceivedDateTime desc"
)
query = urlencode({
"$top": max(1, min(int(top or FETCH_LIMIT_DEFAULT), 30)),
"$select": "Id,Subject,From,BodyPreview,ReceivedDateTime",
"$orderby": "ReceivedDateTime desc",
})
url = f"{OUTLOOK_API_ORIGIN}/api/v2.0/me/mailfolders/{mailbox_id}/messages?{query}"
try:
_, payload = get_json(url, headers={
"Accept": "application/json",
@@ -637,6 +725,7 @@ def collect_messages(email_addr, client_id, refresh_token, mailboxes, top):
try:
log_info(f"message collection start transport={transport_name}")
result = collector(email_addr, client_id, refresh_token, mailboxes, top)
log_openai_messages(result.get("messages") or [], transport=transport_name)
log_info(
f"message collection success transport={transport_name} "
f"tokenEndpoint={result['token_payload'].get('token_endpoint', '')}"
@@ -679,6 +768,10 @@ def select_latest_code(messages, sender_filters, subject_filters, exclude_codes,
preview = str(message.get("bodyPreview", ""))
combined = " ".join([sender, subject.lower(), preview.lower()])
code = extract_code(" ".join([subject, preview, sender]))
if not code:
body_content = get_message_body_content(message)
if body_content:
code = extract_code(" ".join([subject, body_content, sender]))
if not code or code in excluded:
return None
+269
View File
@@ -0,0 +1,269 @@
(function attachSidepanelFormDialog(globalScope) {
const FORM_EYE_OPEN_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>';
const FORM_EYE_CLOSED_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.94 10.94 0 0 1 12 19C5 19 1 12 1 12a21.77 21.77 0 0 1 5.06-6.94"/><path d="M9.9 4.24A10.94 10.94 0 0 1 12 5c7 0 11 7 11 7a21.86 21.86 0 0 1-2.16 3.19"/><path d="M1 1l22 22"/><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/></svg>';
function syncPasswordToggleButton(button, input, labels) {
if (!button || !input) return;
const isHidden = input.type === 'password';
button.innerHTML = isHidden ? FORM_EYE_OPEN_ICON : FORM_EYE_CLOSED_ICON;
button.setAttribute('aria-label', isHidden ? labels.show : labels.hide);
button.title = isHidden ? labels.show : labels.hide;
}
function createFormDialog(context = {}) {
const {
overlay = null,
titleNode = null,
closeButton = null,
messageNode = null,
alertNode = null,
fieldsContainer = null,
cancelButton = null,
confirmButton = null,
documentRef = globalScope.document,
} = context;
let resolver = null;
let currentConfig = null;
let currentInputs = [];
function setHidden(node, hidden) {
if (!node) return;
node.hidden = Boolean(hidden);
}
function resetAlert() {
if (!alertNode) return;
alertNode.textContent = '';
alertNode.className = 'modal-alert modal-form-alert';
alertNode.hidden = true;
}
function setAlert(message = '', tone = 'danger') {
if (!alertNode) return;
const text = String(message || '').trim();
if (!text) {
resetAlert();
return;
}
alertNode.textContent = text;
alertNode.className = `modal-alert modal-form-alert${tone === 'danger' ? ' is-danger' : ''}`;
alertNode.hidden = false;
}
function close(result = null) {
if (resolver) {
resolver(result);
resolver = null;
}
currentConfig = null;
currentInputs = [];
resetAlert();
if (fieldsContainer) {
fieldsContainer.innerHTML = '';
}
if (overlay) {
overlay.hidden = true;
}
}
function buildFieldNode(field, values) {
const wrapper = documentRef.createElement('div');
wrapper.className = 'modal-form-row';
const label = documentRef.createElement('label');
label.className = 'modal-form-label';
const labelText = String(field.label || field.key || '').trim();
label.textContent = labelText;
wrapper.appendChild(label);
let input = null;
if (field.type === 'textarea') {
input = documentRef.createElement('textarea');
input.className = 'data-textarea';
} else if (field.type === 'select') {
input = documentRef.createElement('select');
input.className = 'data-select';
const options = Array.isArray(field.options) ? field.options : [];
options.forEach((option) => {
const optionNode = documentRef.createElement('option');
optionNode.value = String(option?.value || '');
optionNode.textContent = String(option?.label || option?.value || '');
input.appendChild(optionNode);
});
} else {
input = documentRef.createElement('input');
input.type = field.type === 'password' ? 'password' : 'text';
input.className = field.type === 'password'
? 'data-input data-input-with-icon'
: 'data-input';
}
const normalizedValue = Object.prototype.hasOwnProperty.call(values, field.key)
? values[field.key]
: field.value;
if (normalizedValue !== undefined && normalizedValue !== null) {
input.value = String(normalizedValue);
}
if (field.placeholder) {
input.placeholder = String(field.placeholder);
}
if (field.autocomplete) {
input.autocomplete = String(field.autocomplete);
}
if (field.inputMode) {
input.inputMode = String(field.inputMode);
}
if (field.rows && field.type === 'textarea') {
input.rows = Number(field.rows) || 3;
}
input.dataset.fieldKey = String(field.key || '');
label.htmlFor = field.key;
input.id = field.key;
if (field.type === 'password') {
const inputShell = documentRef.createElement('div');
inputShell.className = 'input-with-icon';
inputShell.appendChild(input);
const toggleButton = documentRef.createElement('button');
toggleButton.className = 'input-icon-btn';
toggleButton.type = 'button';
const labels = {
show: String(field.showPasswordLabel || `\u663e\u793a${labelText || '\u5bc6\u7801'}`),
hide: String(field.hidePasswordLabel || `\u9690\u85cf${labelText || '\u5bc6\u7801'}`),
};
syncPasswordToggleButton(toggleButton, input, labels);
toggleButton.addEventListener('click', () => {
input.type = input.type === 'password' ? 'text' : 'password';
syncPasswordToggleButton(toggleButton, input, labels);
});
inputShell.appendChild(toggleButton);
wrapper.appendChild(inputShell);
} else {
wrapper.appendChild(input);
}
if (field.type !== 'textarea') {
input.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') {
return;
}
event.preventDefault();
void handleConfirm();
});
}
currentInputs.push({ field, input });
return wrapper;
}
function collectValues() {
return currentInputs.reduce((result, item) => {
result[item.field.key] = item.input.value;
return result;
}, {});
}
async function handleConfirm() {
if (!currentConfig) {
close(null);
return;
}
const values = collectValues();
resetAlert();
for (const item of currentInputs) {
const { field, input } = item;
const rawValue = values[field.key];
const textValue = String(rawValue || '').trim();
if (field.required && !textValue) {
setAlert(field.requiredMessage || `${field.label || field.key}不能为空。`);
input.focus?.();
return;
}
if (typeof field.validate === 'function') {
const validationMessage = await field.validate(rawValue, values);
if (validationMessage) {
setAlert(validationMessage);
input.focus?.();
return;
}
}
}
close(values);
}
function bindEvents() {
overlay?.addEventListener('click', (event) => {
if (event.target === overlay) {
close(null);
}
});
closeButton?.addEventListener('click', () => close(null));
cancelButton?.addEventListener('click', () => close(null));
confirmButton?.addEventListener('click', () => {
void handleConfirm();
});
}
async function open(config = {}) {
if (!overlay || !titleNode || !fieldsContainer || !confirmButton) {
return null;
}
if (resolver) {
close(null);
}
currentConfig = config || {};
currentInputs = [];
titleNode.textContent = String(currentConfig.title || '填写表单');
if (messageNode) {
const message = String(currentConfig.message || '').trim();
messageNode.textContent = message;
setHidden(messageNode, !message);
}
resetAlert();
if (currentConfig.alert?.text) {
setAlert(currentConfig.alert.text, currentConfig.alert.tone || 'danger');
}
confirmButton.textContent = String(currentConfig.confirmLabel || '确认');
confirmButton.className = `btn ${currentConfig.confirmVariant || 'btn-primary'} btn-sm`;
fieldsContainer.innerHTML = '';
const initialValues = currentConfig.initialValues && typeof currentConfig.initialValues === 'object'
? currentConfig.initialValues
: {};
const fields = Array.isArray(currentConfig.fields) ? currentConfig.fields : [];
fields.forEach((field) => {
fieldsContainer.appendChild(buildFieldNode(field, initialValues));
});
overlay.hidden = false;
const firstInput = currentInputs[0]?.input || null;
if (firstInput && typeof globalScope.requestAnimationFrame === 'function') {
globalScope.requestAnimationFrame(() => firstInput.focus?.());
} else {
firstInput?.focus?.();
}
return new Promise((resolve) => {
resolver = resolve;
});
}
bindEvents();
return {
close,
open,
};
}
globalScope.SidepanelFormDialog = {
createFormDialog,
};
})(window);
+69 -21
View File
@@ -14,6 +14,47 @@ const ipProxyActionState = {
busy: false,
action: '',
};
const IP_PROXY_SECTION_EXPANDED_STORAGE_KEY = 'multipage-ip-proxy-section-expanded';
let ipProxySectionExpanded = false;
function readIpProxySectionExpanded() {
try {
return globalThis.localStorage?.getItem(IP_PROXY_SECTION_EXPANDED_STORAGE_KEY) === '1';
} catch (err) {
return false;
}
}
function persistIpProxySectionExpanded(expanded) {
try {
if (expanded) {
globalThis.localStorage?.setItem(IP_PROXY_SECTION_EXPANDED_STORAGE_KEY, '1');
} else {
globalThis.localStorage?.removeItem(IP_PROXY_SECTION_EXPANDED_STORAGE_KEY);
}
} catch (err) {
// Ignore storage errors; the in-memory collapsed state is still enough for this session.
}
}
function setIpProxySectionExpanded(expanded) {
ipProxySectionExpanded = Boolean(expanded);
persistIpProxySectionExpanded(ipProxySectionExpanded);
if (typeof updateIpProxyUI === 'function') {
updateIpProxyUI(latestState);
}
}
function toggleIpProxySectionExpanded() {
setIpProxySectionExpanded(!ipProxySectionExpanded);
}
function initIpProxySectionExpandedState() {
ipProxySectionExpanded = readIpProxySectionExpanded();
if (typeof updateIpProxyUI === 'function') {
updateIpProxyUI(latestState);
}
}
function normalizeIpProxyActionType(value = '') {
const normalized = String(value || '').trim().toLowerCase();
@@ -1158,6 +1199,7 @@ function setIpProxyEnabledInlineStatus(state = {}, enabled = getSelectedIpProxyE
function updateIpProxyUI(state = latestState) {
const enabled = getSelectedIpProxyEnabled();
const showSettings = enabled && ipProxySectionExpanded;
const mode = getSelectedIpProxyMode();
const service = normalizeIpProxyService(selectIpProxyService?.value || state?.ipProxyService || DEFAULT_IP_PROXY_SERVICE);
const apiModeAvailable = isIpProxyApiModeAvailable();
@@ -1172,64 +1214,70 @@ function updateIpProxyUI(state = latestState) {
const busyAction = normalizeIpProxyActionType(actionState.action);
const runtimeState = state || latestState || {};
setIpProxyEnabledInlineStatus(runtimeState, enabled);
if (rowIpProxyEnabled) {
rowIpProxyEnabled.style.display = '';
}
if (btnToggleIpProxySection) {
btnToggleIpProxySection.disabled = !enabled;
btnToggleIpProxySection.textContent = showSettings ? '收起设置' : '展开设置';
btnToggleIpProxySection.title = enabled
? (showSettings ? '收起 IP 代理设置' : '展开 IP 代理设置')
: '开启 IP 代理后可展开设置';
btnToggleIpProxySection.setAttribute('aria-expanded', String(showSettings));
}
if (rowIpProxyFold) {
rowIpProxyFold.style.display = enabled ? '' : 'none';
rowIpProxyFold.style.display = showSettings ? '' : 'none';
}
if (rowIpProxyService) {
rowIpProxyService.style.display = enabled ? '' : 'none';
rowIpProxyService.style.display = showSettings ? '' : 'none';
}
if (rowIpProxyMode) {
rowIpProxyMode.style.display = enabled ? '' : 'none';
rowIpProxyMode.style.display = showSettings ? '' : 'none';
}
if (rowIpProxyLayout) {
rowIpProxyLayout.style.display = enabled ? '' : 'none';
rowIpProxyLayout.style.display = showSettings ? '' : 'none';
}
if (rowIpProxyApiUrl) {
rowIpProxyApiUrl.style.display = enabled && apiModeAvailable && isApiMode ? '' : 'none';
rowIpProxyApiUrl.style.display = showSettings && apiModeAvailable && isApiMode ? '' : 'none';
}
if (rowIpProxyAccountList) {
rowIpProxyAccountList.style.display = enabled && isAccountMode && accountListAvailable ? '' : 'none';
rowIpProxyAccountList.style.display = showSettings && isAccountMode && accountListAvailable ? '' : 'none';
}
if (rowIpProxyAccountSessionPrefix) {
rowIpProxyAccountSessionPrefix.style.display = enabled && showSessionOptions ? '' : 'none';
rowIpProxyAccountSessionPrefix.style.display = showSettings && showSessionOptions ? '' : 'none';
}
if (rowIpProxyAccountLifeMinutes) {
rowIpProxyAccountLifeMinutes.style.display = enabled && showSessionOptions ? '' : 'none';
rowIpProxyAccountLifeMinutes.style.display = showSettings && showSessionOptions ? '' : 'none';
}
if (rowIpProxyPoolTargetCount) {
rowIpProxyPoolTargetCount.style.display = enabled ? '' : 'none';
rowIpProxyPoolTargetCount.style.display = showSettings ? '' : 'none';
}
if (rowIpProxyHost) {
rowIpProxyHost.style.display = enabled && isAccountMode ? '' : 'none';
rowIpProxyHost.style.display = showSettings && isAccountMode ? '' : 'none';
}
if (rowIpProxyPort) {
rowIpProxyPort.style.display = enabled && isAccountMode ? '' : 'none';
rowIpProxyPort.style.display = showSettings && isAccountMode ? '' : 'none';
}
if (rowIpProxyProtocol) {
rowIpProxyProtocol.style.display = enabled ? '' : 'none';
rowIpProxyProtocol.style.display = showSettings ? '' : 'none';
}
if (rowIpProxyUsername) {
rowIpProxyUsername.style.display = enabled && isAccountMode ? '' : 'none';
rowIpProxyUsername.style.display = showSettings && isAccountMode ? '' : 'none';
}
if (rowIpProxyPassword) {
rowIpProxyPassword.style.display = enabled && isAccountMode ? '' : 'none';
rowIpProxyPassword.style.display = showSettings && isAccountMode ? '' : 'none';
}
if (rowIpProxyRegion) {
rowIpProxyRegion.style.display = enabled && isAccountMode ? '' : 'none';
rowIpProxyRegion.style.display = showSettings && isAccountMode ? '' : 'none';
}
if (rowIpProxyActions) {
rowIpProxyActions.style.display = enabled ? '' : 'none';
rowIpProxyActions.style.display = showSettings ? '' : 'none';
}
if (ipProxyActionButtons) {
ipProxyActionButtons.style.display = enabled ? '' : 'none';
ipProxyActionButtons.style.display = showSettings ? '' : 'none';
}
if (rowIpProxyRuntimeStatus) {
rowIpProxyRuntimeStatus.style.display = enabled ? '' : 'none';
rowIpProxyRuntimeStatus.style.display = showSettings ? '' : 'none';
}
if (ipProxyLayout) {
ipProxyLayout.classList.toggle('is-account-only', !apiModeAvailable);
@@ -1256,7 +1304,7 @@ function updateIpProxyUI(state = latestState) {
ipProxyApiPanel.classList.toggle('is-disabled', !apiModeAvailable);
ipProxyApiPanel.setAttribute('aria-disabled', String(!apiModeAvailable));
ipProxyApiPanel.hidden = !apiModeAvailable;
ipProxyApiPanel.style.display = enabled && apiModeAvailable ? '' : 'none';
ipProxyApiPanel.style.display = showSettings && apiModeAvailable ? '' : 'none';
}
if (inputIpProxyApiUrl) {
inputIpProxyApiUrl.disabled = !enabled || !apiModeAvailable;
+191
View File
@@ -0,0 +1,191 @@
(function attachSidepanelPayPalManager(globalScope) {
function createPayPalManager(context = {}) {
const {
state,
dom,
helpers,
runtime,
paypalUtils = {},
} = context;
let actionInFlight = false;
function getPayPalAccounts(currentState = state.getLatestState()) {
return helpers.getPayPalAccounts(currentState);
}
function getCurrentPayPalAccountId(currentState = state.getLatestState()) {
return String(currentState?.currentPayPalAccountId || '').trim();
}
function buildSelectOptions(accounts = []) {
if (!accounts.length) {
return '<option value="">请先添加 PayPal 账号</option>';
}
return accounts.map((account) => (
`<option value="${helpers.escapeHtml(account.id)}">${helpers.escapeHtml(account.email || '(未命名账号)')}</option>`
)).join('');
}
function applyPayPalAccountMutation(account) {
if (!account?.id) return;
const latestState = state.getLatestState();
const nextAccounts = typeof paypalUtils.upsertPayPalAccountInList === 'function'
? paypalUtils.upsertPayPalAccountInList(getPayPalAccounts(latestState), account)
: [...getPayPalAccounts(latestState), account];
state.syncLatestState({ paypalAccounts: nextAccounts });
renderPayPalAccounts();
}
function renderPayPalAccounts() {
if (!dom.selectPayPalAccount) return;
const latestState = state.getLatestState();
const accounts = getPayPalAccounts(latestState);
const currentId = getCurrentPayPalAccountId(latestState);
dom.selectPayPalAccount.innerHTML = buildSelectOptions(accounts);
dom.selectPayPalAccount.disabled = accounts.length === 0;
dom.selectPayPalAccount.value = accounts.some((account) => account.id === currentId) ? currentId : '';
}
async function syncSelectedPayPalAccount(options = {}) {
const { silent = false } = options;
const accountId = String(dom.selectPayPalAccount?.value || '').trim();
if (!accountId) {
state.syncLatestState({
currentPayPalAccountId: null,
paypalEmail: '',
paypalPassword: '',
});
renderPayPalAccounts();
return null;
}
const response = await runtime.sendMessage({
type: 'SELECT_PAYPAL_ACCOUNT',
source: 'sidepanel',
payload: { accountId },
});
if (response?.error) {
throw new Error(response.error);
}
state.syncLatestState({
currentPayPalAccountId: response.account?.id || accountId,
paypalEmail: String(response.account?.email || '').trim(),
paypalPassword: String(response.account?.password || ''),
});
renderPayPalAccounts();
if (!silent) {
helpers.showToast(`已切换当前 PayPal 账号为 ${response.account?.email || accountId}`, 'success', 1800);
}
return response.account || null;
}
async function openPayPalAccountDialog() {
if (typeof helpers.openFormDialog !== 'function') {
throw new Error('表单弹窗能力未加载,请刷新扩展后重试。');
}
return helpers.openFormDialog({
title: '添加 PayPal 账号',
confirmLabel: '保存账号',
confirmVariant: 'btn-primary',
fields: [
{
key: 'email',
label: 'PayPal 账号',
type: 'text',
placeholder: '请输入 PayPal 登录邮箱',
autocomplete: 'username',
required: true,
requiredMessage: '请先填写 PayPal 账号。',
validate: (value) => {
const normalized = String(value || '').trim();
if (!normalized.includes('@')) {
return 'PayPal 账号需填写邮箱格式。';
}
return '';
},
},
{
key: 'password',
label: 'PayPal 密码',
type: 'password',
placeholder: '请输入 PayPal 登录密码',
autocomplete: 'current-password',
required: true,
requiredMessage: '请先填写 PayPal 密码。',
},
],
});
}
async function handleAddPayPalAccount() {
if (actionInFlight) return;
const formValues = await openPayPalAccountDialog();
if (!formValues) {
return;
}
actionInFlight = true;
if (dom.btnAddPayPalAccount) {
dom.btnAddPayPalAccount.disabled = true;
}
try {
const response = await runtime.sendMessage({
type: 'UPSERT_PAYPAL_ACCOUNT',
source: 'sidepanel',
payload: {
email: String(formValues.email || '').trim(),
password: String(formValues.password || ''),
},
});
if (response?.error) {
throw new Error(response.error);
}
applyPayPalAccountMutation(response.account);
if (response.account?.id) {
state.syncLatestState({ currentPayPalAccountId: response.account.id });
renderPayPalAccounts();
dom.selectPayPalAccount.value = response.account.id;
await syncSelectedPayPalAccount({ silent: true });
}
helpers.showToast(`已保存 PayPal 账号 ${response.account?.email || ''}`, 'success', 2200);
} catch (error) {
helpers.showToast(`保存 PayPal 账号失败:${error.message}`, 'error');
throw error;
} finally {
actionInFlight = false;
if (dom.btnAddPayPalAccount) {
dom.btnAddPayPalAccount.disabled = false;
}
}
}
function bindPayPalEvents() {
dom.btnAddPayPalAccount?.addEventListener('click', () => {
void handleAddPayPalAccount();
});
dom.selectPayPalAccount?.addEventListener('change', () => {
void syncSelectedPayPalAccount().catch((error) => {
helpers.showToast(error.message, 'error');
renderPayPalAccounts();
});
});
}
return {
bindPayPalEvents,
renderPayPalAccounts,
syncSelectedPayPalAccount,
};
}
globalScope.SidepanelPayPalManager = {
createPayPalManager,
};
})(window);
+65 -41
View File
@@ -785,6 +785,37 @@ header {
display: block;
}
.ip-proxy-card {
margin-top: 10px;
}
.ip-proxy-header-actions {
flex: 0 0 auto;
align-items: center;
}
#btn-toggle-ip-proxy-section {
white-space: nowrap;
}
.section-collapse-body {
display: flex;
flex-direction: column;
gap: 9px;
}
.section-collapse-body[hidden] {
display: none;
}
#btn-toggle-hotmail-section {
white-space: nowrap;
}
#btn-toggle-cloudflare-temp-email-section {
white-space: nowrap;
}
.ip-proxy-fold {
width: 100%;
border: none;
@@ -858,11 +889,6 @@ header {
}
}
.ip-proxy-enabled-inline {
justify-content: flex-end;
gap: 10px;
}
.ip-proxy-actions-inline {
flex-wrap: wrap;
align-items: center;
@@ -888,42 +914,6 @@ header {
color: var(--text-secondary);
}
.ip-proxy-enabled-status {
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
min-width: 92px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
text-align: right;
white-space: nowrap;
}
.ip-proxy-enabled-status-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--text-muted);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--text-muted) 25%, transparent);
flex-shrink: 0;
}
.ip-proxy-enabled-status.is-on {
color: var(--green);
}
.ip-proxy-enabled-status.is-on .ip-proxy-enabled-status-dot {
background: var(--green);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--green) 30%, transparent);
}
.ip-proxy-enabled-status.is-off {
color: var(--text-muted);
}
.ip-proxy-runtime-status {
display: inline-flex;
align-items: flex-start;
@@ -2722,6 +2712,40 @@ header {
cursor: pointer;
}
.modal-form-fields {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 14px;
}
.modal-form-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.modal-form-label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.modal-form-row .data-input,
.modal-form-row .data-select,
.modal-form-row .data-textarea {
width: 100%;
}
.modal-form-row .input-with-icon {
width: 100%;
}
.modal-form-alert {
margin-top: -4px;
margin-bottom: 14px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
+326 -257
View File
@@ -184,7 +184,13 @@
</div>
<div class="data-row" id="row-sub2api-password" style="display:none;">
<span class="data-label">密码</span>
<input type="password" id="input-sub2api-password" class="data-input" placeholder="请输入 SUB2API 登录密码" />
<div class="input-with-icon">
<input type="password" id="input-sub2api-password" class="data-input data-input-with-icon"
placeholder="请输入 SUB2API 登录密码" />
<button id="btn-toggle-sub2api-password" class="input-icon-btn" type="button"
data-password-toggle="input-sub2api-password" data-show-label="显示 SUB2API 密码"
data-hide-label="隐藏 SUB2API 密码" aria-label="显示 SUB2API 密码" title="显示 SUB2API 密码"></button>
</div>
</div>
<div class="data-row" id="row-sub2api-group" style="display:none;">
<span class="data-label">分组</span>
@@ -194,163 +200,6 @@
<span class="data-label">默认代理</span>
<input type="text" id="input-sub2api-default-proxy" class="data-input" placeholder="留空则不使用代理;或填写代理名称 / ID" />
</div>
<div class="data-row" id="row-ip-proxy-enabled">
<span class="data-label">IP代理</span>
<div class="data-inline ip-proxy-enabled-inline">
<label class="toggle-switch" for="input-ip-proxy-enabled" title="启用或禁用 IP 代理接管">
<input type="checkbox" id="input-ip-proxy-enabled" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
</label>
<span id="ip-proxy-enabled-status" class="ip-proxy-enabled-status" aria-live="polite">
<span id="ip-proxy-enabled-status-dot" class="ip-proxy-enabled-status-dot" aria-hidden="true"></span>
<span id="ip-proxy-enabled-status-text" class="ip-proxy-enabled-status-text">未开启</span>
</span>
</div>
</div>
<div class="data-row ip-proxy-fold-row" id="row-ip-proxy-fold" style="display:none;">
<div id="ip-proxy-fold" class="ip-proxy-fold">
<div class="ip-proxy-fold-body">
<div class="data-row" id="row-ip-proxy-service" style="display:none;">
<span class="data-label">代理服务</span>
<div class="data-inline">
<select id="select-ip-proxy-service" class="data-select" disabled>
<option value="711proxy">711Proxy(首版)</option>
</select>
<button id="btn-ip-proxy-service-login" class="btn btn-outline btn-sm data-inline-btn" type="button">
登录
</button>
</div>
</div>
<div class="data-row" id="row-ip-proxy-mode" style="display:none;">
<span class="data-label">代理模式</span>
<div id="ip-proxy-mode-group" class="choice-group" role="group" aria-label="IP代理模式">
<button type="button" class="choice-btn" data-ip-proxy-mode="account">账号密码</button>
<button type="button" class="choice-btn" data-ip-proxy-mode="api" disabled title="首版暂未开放">API 拉取(暂未开放)</button>
</div>
</div>
<div class="data-row ip-proxy-layout-row" id="row-ip-proxy-layout" style="display:none;">
<div class="ip-proxy-layout" id="ip-proxy-layout">
<div class="ip-proxy-column ip-proxy-column-account" id="ip-proxy-account-panel">
<div class="ip-proxy-column-header">账号密码模式</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-account-list" style="display:none;">
<span class="data-label">账号代理列表</span>
<textarea id="input-ip-proxy-account-list" class="data-textarea mono"
placeholder="每行一条:host:port 或 host:port:username:password&#10;例如 global.rotgb.711proxy.com:10000:username:password"></textarea>
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-account-session-prefix" style="display:none;">
<span class="data-label">会话(session)</span>
<input type="text" id="input-ip-proxy-account-session-prefix" class="data-input mono"
placeholder="会话前缀,例如 ZC28qZ0KQL" />
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-account-life-minutes" style="display:none;">
<span class="data-label">时长(life)</span>
<div class="data-inline">
<input type="number" id="input-ip-proxy-account-life-minutes" class="data-input" min="1" max="1440" step="1"
placeholder="5-180(分钟)" title="711Proxy 会话时长范围:5-180 分钟" />
<span class="data-unit">分钟</span>
</div>
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-host" style="display:none;">
<span class="data-label">代理 Host</span>
<input type="text" id="input-ip-proxy-host" class="data-input" placeholder="例如 global.rotgb.711proxy.com" />
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-port" style="display:none;">
<span class="data-label">代理 Port</span>
<input type="number" id="input-ip-proxy-port" class="data-input" min="1" max="65535" step="1"
placeholder="例如 10000" />
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-protocol" style="display:none;">
<span class="data-label">代理协议</span>
<select id="select-ip-proxy-protocol" class="data-select">
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
<option value="socks5">SOCKS5</option>
<option value="socks4">SOCKS4</option>
</select>
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-username" style="display:none;">
<span class="data-label">代理账号</span>
<div class="input-with-icon">
<input type="password" id="input-ip-proxy-username" class="data-input data-input-with-icon"
placeholder="例如 USER047152-zone-custom-region-US-asn-ASN81" />
<button id="btn-toggle-ip-proxy-username" class="input-icon-btn" type="button" aria-label="显示代理账号"
title="显示代理账号"></button>
</div>
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-password" style="display:none;">
<span class="data-label">代理密码</span>
<div class="input-with-icon">
<input type="password" id="input-ip-proxy-password" class="data-input data-input-with-icon"
placeholder="账号密码代理的密码" />
<button id="btn-toggle-ip-proxy-password" class="input-icon-btn" type="button" aria-label="显示代理密码"
title="显示代理密码"></button>
</div>
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-region" style="display:none;">
<span class="data-label">地区参数</span>
<input type="text" id="input-ip-proxy-region" class="data-input" placeholder="可选,例如 US / DE / HK" />
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-pool-target-count" style="display:none;">
<span class="data-label">任务切换阈值</span>
<div class="data-inline">
<input type="number" id="input-ip-proxy-pool-target-count" class="data-input" min="1" max="500" step="1"
placeholder="20(成功轮次)" title="每成功多少轮任务后自动切换一次代理;仅 1 条节点时会跳过自动切换" />
<span class="data-unit"></span>
</div>
</div>
</div>
<div class="ip-proxy-column ip-proxy-column-api" id="ip-proxy-api-panel">
<div class="ip-proxy-column-header">API 模式</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-api-url" style="display:none;">
<span class="data-label">代理API</span>
<div class="input-with-icon">
<input type="password" id="input-ip-proxy-api-url" class="data-input data-input-with-icon"
placeholder="粘贴完整 API 链接" />
<button id="btn-toggle-ip-proxy-api-url" class="input-icon-btn" type="button" aria-label="显示代理 API"
title="显示代理 API"></button>
</div>
</div>
</div>
</div>
</div>
<div class="data-row" id="row-ip-proxy-actions" style="display:none;">
<span class="data-label">切换代理</span>
<div class="data-inline ip-proxy-actions-inline">
<span id="ip-proxy-action-buttons" class="ip-proxy-action-grid">
<button id="btn-ip-proxy-refresh" class="btn btn-outline btn-sm data-inline-btn" type="button">拉取</button>
<button id="btn-ip-proxy-next" class="btn btn-outline btn-sm data-inline-btn" type="button">下一条</button>
<button id="btn-ip-proxy-change" class="btn btn-outline btn-sm data-inline-btn" type="button" title="保持当前会话并刷新出口(仅 711 + session">Change</button>
<button id="btn-ip-proxy-probe" class="btn btn-outline btn-sm data-inline-btn" type="button">检测出口</button>
</span>
<span id="ip-proxy-action-hint" class="ip-proxy-action-hint">
下一条:切到代理池下一条。Change:保持当前 session 重绑链路(仅 711 + session)。
</span>
</div>
</div>
<div class="data-row" id="row-ip-proxy-runtime-status" style="display:none;">
<span class="data-label">代理状态</span>
<div id="ip-proxy-runtime-status" class="ip-proxy-runtime-status" aria-live="polite">
<span id="ip-proxy-runtime-dot" class="ip-proxy-runtime-dot" aria-hidden="true"></span>
<div class="ip-proxy-runtime-content">
<div class="ip-proxy-runtime-main">
<span id="ip-proxy-runtime-text" class="data-value data-value-fill">未启用,沿用浏览器默认/全局代理。</span>
</div>
<div class="ip-proxy-runtime-meta">
<span id="ip-proxy-current" class="ip-proxy-runtime-current mono truncate">未启用</span>
<button id="btn-ip-proxy-check-ip" class="btn btn-outline btn-xs data-inline-btn ip-proxy-check-ip-btn" type="button">检查IP</button>
</div>
<details id="ip-proxy-runtime-details" class="ip-proxy-runtime-details" hidden>
<summary>查看详细信息</summary>
<div id="ip-proxy-runtime-details-text" class="ip-proxy-runtime-details-text"></div>
</details>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="data-row" id="row-codex2api-url" style="display:none;">
<span class="data-label">Codex2API</span>
<input type="text" id="input-codex2api-url" class="data-input"
@@ -358,8 +207,14 @@
</div>
<div class="data-row" id="row-codex2api-admin-key" style="display:none;">
<span class="data-label">管理密钥</span>
<input type="password" id="input-codex2api-admin-key" class="data-input"
placeholder="请输入 Codex2API Admin Secret" />
<div class="input-with-icon">
<input type="password" id="input-codex2api-admin-key" class="data-input data-input-with-icon"
placeholder="请输入 Codex2API Admin Secret" />
<button id="btn-toggle-codex2api-admin-key" class="input-icon-btn" type="button"
data-password-toggle="input-codex2api-admin-key" data-show-label="显示 Codex2API 管理密钥"
data-hide-label="隐藏 Codex2API 管理密钥" aria-label="显示 Codex2API 管理密钥"
title="显示 Codex2API 管理密钥"></button>
</div>
</div>
<div class="data-row" id="row-custom-password">
<span class="data-label">账户密码</span>
@@ -383,13 +238,14 @@
<span class="setting-caption">PayPal 订阅链路</span>
</div>
</div>
<div class="data-row" id="row-paypal-email" style="display:none;">
<div class="data-row" id="row-paypal-account" style="display:none;">
<span class="data-label">PayPal 账号</span>
<input type="text" id="input-paypal-email" class="data-input" placeholder="请输入 PayPal 登录邮箱" />
</div>
<div class="data-row" id="row-paypal-password" style="display:none;">
<span class="data-label">PayPal 密码</span>
<input type="password" id="input-paypal-password" class="data-input" placeholder="请输入 PayPal 登录密码" />
<div class="data-inline">
<select id="select-paypal-account" class="data-select">
<option value="">请先添加 PayPal 账号</option>
</select>
<button id="btn-add-paypal-account" class="btn btn-outline btn-sm data-inline-btn" type="button">添加</button>
</div>
</div>
<div class="data-row">
<span class="data-label">邮箱服务</span>
@@ -569,9 +425,20 @@
<option value="52" selected>Thailand</option>
</select>
</div>
<div class="data-row" id="row-hero-sms-max-price" style="display:none;">
<span class="data-label">最高价格</span>
<input type="number" id="input-hero-sms-max-price" class="data-input mono" min="0.0001" step="0.0001" required
placeholder="必填,例如 0.08" />
</div>
<div class="data-row" id="row-hero-sms-api-key" style="display:none;">
<span class="data-label">接码 API</span>
<input type="password" id="input-hero-sms-api-key" class="data-input mono" placeholder="请输入 HeroSMS API Key" />
<div class="input-with-icon">
<input type="password" id="input-hero-sms-api-key" class="data-input data-input-with-icon mono"
placeholder="请输入 HeroSMS API Key" />
<button id="btn-toggle-hero-sms-api-key" class="input-icon-btn" type="button"
data-password-toggle="input-hero-sms-api-key" data-show-label="显示 HeroSMS API Key"
data-hide-label="隐藏 HeroSMS API Key" aria-label="显示 HeroSMS API Key" title="显示 HeroSMS API Key"></button>
</div>
</div>
<div class="data-row">
<span class="data-label">OAuth</span>
@@ -585,6 +452,147 @@
</div>
</div>
</div>
<div id="ip-proxy-section" class="data-card ip-proxy-card">
<div class="section-mini-header">
<div class="section-mini-copy">
<span class="section-label">IP 代理</span>
<span class="data-value">用于浏览器代理接管与出口切换</span>
</div>
<div id="row-ip-proxy-enabled" class="section-mini-actions ip-proxy-header-actions">
<button id="btn-toggle-ip-proxy-section" class="btn btn-ghost btn-xs" type="button"
aria-expanded="false" aria-controls="row-ip-proxy-fold">展开设置</button>
<label class="toggle-switch" for="input-ip-proxy-enabled" title="启用或禁用 IP 代理接管">
<input type="checkbox" id="input-ip-proxy-enabled" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
</label>
</div>
</div>
<div class="data-row ip-proxy-fold-row" id="row-ip-proxy-fold" style="display:none;">
<div id="ip-proxy-fold" class="ip-proxy-fold">
<div class="ip-proxy-fold-body">
<div class="data-row" id="row-ip-proxy-service" style="display:none;">
<span class="data-label">代理服务</span>
<div class="data-inline">
<select id="select-ip-proxy-service" class="data-select" disabled>
<option value="711proxy">711Proxy(首版)</option>
</select>
<button id="btn-ip-proxy-service-login" class="btn btn-outline btn-sm data-inline-btn" type="button">
注册
</button>
</div>
</div>
<div class="data-row" id="row-ip-proxy-mode" style="display:none;">
<span class="data-label">代理模式</span>
<div id="ip-proxy-mode-group" class="choice-group" role="group" aria-label="IP代理模式">
<button type="button" class="choice-btn" data-ip-proxy-mode="account">账号密码</button>
<button type="button" class="choice-btn" data-ip-proxy-mode="api" disabled title="首版暂未开放">API 拉取(暂未开放)</button>
</div>
</div>
<div class="data-row ip-proxy-layout-row" id="row-ip-proxy-layout" style="display:none;">
<div class="ip-proxy-layout" id="ip-proxy-layout">
<div class="ip-proxy-column ip-proxy-column-account" id="ip-proxy-account-panel">
<div class="ip-proxy-column-header">账号密码模式</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-account-list" style="display:none;">
<span class="data-label">账号代理列表</span>
<textarea id="input-ip-proxy-account-list" class="data-textarea mono"
placeholder="每行一条:host:port 或 host:port:username:password&#10;例如 global.rotgb.711proxy.com:10000:username:password"></textarea>
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-account-session-prefix" style="display:none;">
<span class="data-label">会话(session)</span>
<input type="text" id="input-ip-proxy-account-session-prefix" class="data-input mono"
placeholder="会话前缀,例如 ZC28qZ0KQL" />
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-account-life-minutes" style="display:none;">
<span class="data-label">时长(life)</span>
<div class="data-inline">
<input type="number" id="input-ip-proxy-account-life-minutes" class="data-input" min="1" max="1440" step="1"
placeholder="5-180(分钟)" title="711Proxy 会话时长范围:5-180 分钟" />
<span class="data-unit">分钟</span>
</div>
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-host" style="display:none;">
<span class="data-label">代理 Host</span>
<input type="text" id="input-ip-proxy-host" class="data-input" placeholder="例如 global.rotgb.711proxy.com" />
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-port" style="display:none;">
<span class="data-label">代理 Port</span>
<input type="number" id="input-ip-proxy-port" class="data-input" min="1" max="65535" step="1"
placeholder="例如 10000" />
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-protocol" style="display:none;">
<span class="data-label">代理协议</span>
<select id="select-ip-proxy-protocol" class="data-select">
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
<option value="socks5">SOCKS5</option>
<option value="socks4">SOCKS4</option>
</select>
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-username" style="display:none;">
<span class="data-label">代理账号</span>
<div class="input-with-icon">
<input type="password" id="input-ip-proxy-username" class="data-input data-input-with-icon"
placeholder="例如 USER047152-zone-custom-region-US-asn-ASN81" />
<button id="btn-toggle-ip-proxy-username" class="input-icon-btn" type="button" aria-label="显示代理账号"
title="显示代理账号"></button>
</div>
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-password" style="display:none;">
<span class="data-label">代理密码</span>
<div class="input-with-icon">
<input type="password" id="input-ip-proxy-password" class="data-input data-input-with-icon"
placeholder="账号密码代理的密码" />
<button id="btn-toggle-ip-proxy-password" class="input-icon-btn" type="button" aria-label="显示代理密码"
title="显示代理密码"></button>
</div>
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-region" style="display:none;">
<span class="data-label">地区参数</span>
<input type="text" id="input-ip-proxy-region" class="data-input" placeholder="可选,例如 US / DE / HK" />
</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-pool-target-count" style="display:none;">
<span class="data-label">任务切换阈值</span>
<div class="data-inline">
<input type="number" id="input-ip-proxy-pool-target-count" class="data-input" min="1" max="500" step="1"
placeholder="20(成功轮次)" title="每成功多少轮任务后自动切换一次代理;仅 1 条节点时会跳过自动切换" />
<span class="data-unit"></span>
</div>
</div>
</div>
<div class="ip-proxy-column ip-proxy-column-api" id="ip-proxy-api-panel">
<div class="ip-proxy-column-header">API 模式</div>
<div class="data-row ip-proxy-column-row" id="row-ip-proxy-api-url" style="display:none;">
<span class="data-label">代理API</span>
<div class="input-with-icon">
<input type="password" id="input-ip-proxy-api-url" class="data-input data-input-with-icon"
placeholder="粘贴完整 API 链接" />
<button id="btn-toggle-ip-proxy-api-url" class="input-icon-btn" type="button" aria-label="显示代理 API"
title="显示代理 API"></button>
</div>
</div>
</div>
</div>
</div>
<div class="data-row" id="row-ip-proxy-actions" style="display:none;">
<span class="data-label">切换代理</span>
<div class="data-inline ip-proxy-actions-inline">
<span id="ip-proxy-action-buttons" class="ip-proxy-action-grid">
<button id="btn-ip-proxy-refresh" class="btn btn-outline btn-sm data-inline-btn" type="button">拉取</button>
<button id="btn-ip-proxy-next" class="btn btn-outline btn-sm data-inline-btn" type="button">下一条</button>
<button id="btn-ip-proxy-change" class="btn btn-outline btn-sm data-inline-btn" type="button" title="保持当前会话并刷新出口(仅 711 + session">Change</button>
<button id="btn-ip-proxy-probe" class="btn btn-outline btn-sm data-inline-btn" type="button">检测出口</button>
</span>
<span id="ip-proxy-action-hint" class="ip-proxy-action-hint">
下一条:切到代理池下一条。Change:保持当前 session 重绑链路(仅 711 + session)。
</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="cloudflare-temp-email-section" class="data-card hotmail-card" style="display:none;">
<div class="section-mini-header">
<div class="section-mini-copy">
@@ -592,50 +600,64 @@
<span class="data-value">用于生成邮箱或接收转发邮件</span>
</div>
<div class="section-mini-actions">
<button id="btn-toggle-cloudflare-temp-email-section" class="btn btn-ghost btn-xs" type="button"
aria-expanded="false" aria-controls="cloudflare-temp-email-section-body">展开设置</button>
<button id="btn-cloudflare-temp-email-usage-guide" class="btn btn-ghost btn-xs" type="button">使用教程</button>
<button id="btn-cloudflare-temp-email-github" class="btn btn-ghost btn-xs" type="button">GitHub</button>
</div>
</div>
<div class="data-row" id="row-temp-email-base-url" style="display:none;">
<span class="data-label">Temp API</span>
<input type="text" id="input-temp-email-base-url" class="data-input" placeholder="https://your-worker-domain" />
</div>
<div class="data-row" id="row-temp-email-admin-auth" style="display:none;">
<span class="data-label">Admin Auth</span>
<input type="password" id="input-temp-email-admin-auth" class="data-input data-input-with-icon"
placeholder="Cloudflare Temp Email admin password" />
</div>
<div class="data-row" id="row-temp-email-custom-auth" style="display:none;">
<span class="data-label">Custom Auth</span>
<input type="password" id="input-temp-email-custom-auth" class="data-input data-input-with-icon"
placeholder="仅当站点启用了访问密码时再填写;这是额外鉴权,不替代 Admin Auth。" />
</div>
<div class="data-row" id="row-temp-email-receive-mailbox" style="display:none;">
<span class="data-label">邮件接收</span>
<input type="text" id="input-temp-email-receive-mailbox" class="data-input"
placeholder="用于接收转发邮件的邮箱,例如 1@email.example.com" />
</div>
<div class="data-row" id="row-temp-email-random-subdomain-toggle" style="display:none;">
<span class="data-label">随机子域</span>
<div class="data-inline">
<label class="toggle-switch" for="input-temp-email-use-random-subdomain"
title="依赖后端 RANDOM_SUBDOMAIN_DOMAINS 配置">
<input type="checkbox" id="input-temp-email-use-random-subdomain" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
<span>启用</span>
</label>
<span class="setting-caption">依赖后端 RANDOM_SUBDOMAIN_DOMAINS</span>
<div id="cloudflare-temp-email-section-body" class="section-collapse-body" hidden>
<div class="data-row" id="row-temp-email-base-url" style="display:none;">
<span class="data-label">Temp API</span>
<input type="text" id="input-temp-email-base-url" class="data-input" placeholder="https://your-worker-domain" />
</div>
</div>
<div class="data-row" id="row-temp-email-domain" style="display:none;">
<span class="data-label">Temp 域名</span>
<div class="data-inline">
<select id="select-temp-email-domain" class="data-select"></select>
<input type="text" id="input-temp-email-domain" class="data-input" placeholder="例如 mail.example.com"
style="display:none;" />
<button id="btn-temp-email-domain-mode" class="btn btn-outline btn-sm" type="button">添加</button>
<div class="data-row" id="row-temp-email-admin-auth" style="display:none;">
<span class="data-label">Admin Auth</span>
<div class="input-with-icon">
<input type="password" id="input-temp-email-admin-auth" class="data-input data-input-with-icon"
placeholder="Cloudflare Temp Email admin password" />
<button id="btn-toggle-temp-email-admin-auth" class="input-icon-btn" type="button"
data-password-toggle="input-temp-email-admin-auth" data-show-label="显示 Admin Auth"
data-hide-label="隐藏 Admin Auth" aria-label="显示 Admin Auth" title="显示 Admin Auth"></button>
</div>
</div>
<div class="data-row" id="row-temp-email-custom-auth" style="display:none;">
<span class="data-label">Custom Auth</span>
<div class="input-with-icon">
<input type="password" id="input-temp-email-custom-auth" class="data-input data-input-with-icon"
placeholder="仅当站点启用了访问密码时再填写;这是额外鉴权,不替代 Admin Auth。" />
<button id="btn-toggle-temp-email-custom-auth" class="input-icon-btn" type="button"
data-password-toggle="input-temp-email-custom-auth" data-show-label="显示 Custom Auth"
data-hide-label="隐藏 Custom Auth" aria-label="显示 Custom Auth" title="显示 Custom Auth"></button>
</div>
</div>
<div class="data-row" id="row-temp-email-receive-mailbox" style="display:none;">
<span class="data-label">邮件接收</span>
<input type="text" id="input-temp-email-receive-mailbox" class="data-input"
placeholder="用于接收转发邮件的邮箱,例如 1@email.example.com" />
</div>
<div class="data-row" id="row-temp-email-random-subdomain-toggle" style="display:none;">
<span class="data-label">随机子域</span>
<div class="data-inline">
<label class="toggle-switch" for="input-temp-email-use-random-subdomain"
title="依赖后端 RANDOM_SUBDOMAIN_DOMAINS 配置">
<input type="checkbox" id="input-temp-email-use-random-subdomain" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
<span>启用</span>
</label>
<span class="setting-caption">依赖后端 RANDOM_SUBDOMAIN_DOMAINS</span>
</div>
</div>
<div class="data-row" id="row-temp-email-domain" style="display:none;">
<span class="data-label">Temp 域名</span>
<div class="data-inline">
<select id="select-temp-email-domain" class="data-select"></select>
<input type="text" id="input-temp-email-domain" class="data-input" placeholder="例如 mail.example.com"
style="display:none;" />
<button id="btn-temp-email-domain-mode" class="btn btn-outline btn-sm" type="button">添加</button>
</div>
</div>
</div>
</div>
@@ -645,6 +667,8 @@
<span class="section-label">Hotmail 账号池</span>
</div>
<div class="section-mini-actions">
<button id="btn-toggle-hotmail-section" class="btn btn-ghost btn-xs" type="button"
aria-expanded="false" aria-controls="hotmail-section-body">展开设置</button>
<button id="btn-toggle-hotmail-form" class="btn btn-outline btn-xs" type="button"
aria-expanded="false">添加账号</button>
<button id="btn-hotmail-usage-guide" class="btn btn-ghost btn-xs" type="button">使用教程</button>
@@ -654,59 +678,73 @@
aria-expanded="false">展开列表</button>
</div>
</div>
<div class="data-row" id="row-hotmail-service-mode">
<span class="data-label">接码模式</span>
<div id="hotmail-service-mode-group" class="choice-group" role="group" aria-label="Hotmail 接码模式">
<button type="button" class="choice-btn" data-hotmail-service-mode="remote">API对接</button>
<button type="button" class="choice-btn" data-hotmail-service-mode="local">本地助手</button>
</div>
</div>
<div class="data-row" id="row-hotmail-remote-base-url">
<span class="data-label">API对接</span>
<input type="text" id="input-hotmail-remote-base-url" class="data-input mono"
placeholder="微软邮箱 API 对接模式无需填写地址" />
</div>
<div class="data-row" id="row-hotmail-local-base-url" style="display:none;">
<span class="data-label">本地助手</span>
<input type="text" id="input-hotmail-local-base-url" class="data-input mono"
placeholder="http://127.0.0.1:17373" />
</div>
<div id="hotmail-form-shell" class="account-pool-form-shell" hidden>
<div class="data-row">
<span class="data-label">邮箱</span>
<input type="text" id="input-hotmail-email" class="data-input" placeholder="name@hotmail.com" />
</div>
<div class="data-row">
<span class="data-label">客户端 ID</span>
<input type="text" id="input-hotmail-client-id" class="data-input mono" placeholder="微软应用客户端 ID" />
</div>
<div class="data-row">
<span class="data-label">邮箱密码</span>
<input type="password" id="input-hotmail-password" class="data-input" placeholder="可选,仅用于记录" />
</div>
<div class="data-row">
<span class="data-label">刷新令牌</span>
<input type="password" id="input-hotmail-refresh-token" class="data-input mono"
placeholder="必填,粘贴刷新令牌(refresh token" />
</div>
<div class="data-row hotmail-actions-row">
<span class="data-label"></span>
<div class="data-inline account-pool-actions-inline">
<button id="btn-add-hotmail-account" class="btn btn-primary btn-sm" type="button">添加账号</button>
<button id="btn-import-hotmail-accounts" class="btn btn-outline btn-sm account-pool-import-action"
type="button">批量导入</button>
<div id="hotmail-section-body" class="section-collapse-body" hidden>
<div class="data-row" id="row-hotmail-service-mode">
<span class="data-label">接码模式</span>
<div id="hotmail-service-mode-group" class="choice-group" role="group" aria-label="Hotmail 接码模式">
<button type="button" class="choice-btn" data-hotmail-service-mode="remote">API对接</button>
<button type="button" class="choice-btn" data-hotmail-service-mode="local">本地助手</button>
</div>
</div>
<div class="data-row hotmail-import-row">
<span class="data-label">批量导入</span>
<div class="hotmail-import-box">
<textarea id="input-hotmail-import" class="data-textarea mono"
placeholder="账号----密码----客户端ID----刷新令牌&#10;name@hotmail.com----password----client-id----refresh-token"></textarea>
<div class="data-row" id="row-hotmail-remote-base-url">
<span class="data-label">API对接</span>
<input type="text" id="input-hotmail-remote-base-url" class="data-input mono"
placeholder="微软邮箱 API 对接模式无需填写地址" />
</div>
<div class="data-row" id="row-hotmail-local-base-url" style="display:none;">
<span class="data-label">本地助手</span>
<input type="text" id="input-hotmail-local-base-url" class="data-input mono"
placeholder="http://127.0.0.1:17373" />
</div>
<div id="hotmail-form-shell" class="account-pool-form-shell" hidden>
<div class="data-row">
<span class="data-label">邮箱</span>
<input type="text" id="input-hotmail-email" class="data-input" placeholder="name@hotmail.com" />
</div>
<div class="data-row">
<span class="data-label">客户端 ID</span>
<input type="text" id="input-hotmail-client-id" class="data-input mono" placeholder="微软应用客户端 ID" />
</div>
<div class="data-row">
<span class="data-label">邮箱密码</span>
<div class="input-with-icon">
<input type="password" id="input-hotmail-password" class="data-input data-input-with-icon"
placeholder="可选,仅用于记录" />
<button id="btn-toggle-hotmail-password" class="input-icon-btn" type="button"
data-password-toggle="input-hotmail-password" data-show-label="显示 Hotmail 密码"
data-hide-label="隐藏 Hotmail 密码" aria-label="显示 Hotmail 密码" title="显示 Hotmail 密码"></button>
</div>
</div>
<div class="data-row">
<span class="data-label">刷新令牌</span>
<div class="input-with-icon">
<input type="password" id="input-hotmail-refresh-token" class="data-input data-input-with-icon mono"
placeholder="必填,粘贴刷新令牌(refresh token" />
<button id="btn-toggle-hotmail-refresh-token" class="input-icon-btn" type="button"
data-password-toggle="input-hotmail-refresh-token" data-show-label="显示 Hotmail 刷新令牌"
data-hide-label="隐藏 Hotmail 刷新令牌" aria-label="显示 Hotmail 刷新令牌"
title="显示 Hotmail 刷新令牌"></button>
</div>
</div>
<div class="data-row hotmail-actions-row">
<span class="data-label"></span>
<div class="data-inline account-pool-actions-inline">
<button id="btn-add-hotmail-account" class="btn btn-primary btn-sm" type="button">添加账号</button>
<button id="btn-import-hotmail-accounts" class="btn btn-outline btn-sm account-pool-import-action"
type="button">批量导入</button>
</div>
</div>
<div class="data-row hotmail-import-row">
<span class="data-label">批量导入</span>
<div class="hotmail-import-box">
<textarea id="input-hotmail-import" class="data-textarea mono"
placeholder="账号----密码----客户端ID----刷新令牌&#10;name@hotmail.com----password----client-id----refresh-token"></textarea>
</div>
</div>
</div>
</div>
<div id="hotmail-list-shell" class="hotmail-list-shell is-collapsed">
<div id="hotmail-accounts-list" class="hotmail-accounts-list"></div>
<div id="hotmail-list-shell" class="hotmail-list-shell is-collapsed">
<div id="hotmail-accounts-list" class="hotmail-accounts-list"></div>
</div>
</div>
</div>
<div id="mail2925-section" class="data-card hotmail-card" style="display:none;">
@@ -729,7 +767,13 @@
</div>
<div class="data-row">
<span class="data-label">密码</span>
<input type="password" id="input-mail2925-password" class="data-input" placeholder="2925 登录密码" />
<div class="input-with-icon">
<input type="password" id="input-mail2925-password" class="data-input data-input-with-icon"
placeholder="2925 登录密码" />
<button id="btn-toggle-mail2925-password" class="input-icon-btn" type="button"
data-password-toggle="input-mail2925-password" data-show-label="显示 2925 密码"
data-hide-label="隐藏 2925 密码" aria-label="显示 2925 密码" title="显示 2925 密码"></button>
</div>
</div>
<div class="data-row hotmail-actions-row">
<span class="data-label"></span>
@@ -759,7 +803,13 @@
</div>
<div class="data-row">
<span class="data-label">API Key</span>
<input type="password" id="input-luckmail-api-key" class="data-input mono" placeholder="请输入 LuckMail API Key" />
<div class="input-with-icon">
<input type="password" id="input-luckmail-api-key" class="data-input data-input-with-icon mono"
placeholder="请输入 LuckMail API Key" />
<button id="btn-toggle-luckmail-api-key" class="input-icon-btn" type="button"
data-password-toggle="input-luckmail-api-key" data-show-label="显示 LuckMail API Key"
data-hide-label="隐藏 LuckMail API Key" aria-label="显示 LuckMail API Key" title="显示 LuckMail API Key"></button>
</div>
</div>
<div class="data-row">
<span class="data-label">Base URL</span>
@@ -986,6 +1036,22 @@
</div>
</div>
<div id="shared-form-modal" class="modal-overlay" hidden>
<div class="modal-card modal-card-form">
<div class="modal-header">
<span id="shared-form-modal-title" class="modal-title">添加账号</span>
<button id="btn-shared-form-modal-close" class="modal-close" type="button" aria-label="关闭">×</button>
</div>
<p id="shared-form-modal-message" class="modal-message" hidden></p>
<p id="shared-form-modal-alert" class="modal-alert" hidden></p>
<div id="shared-form-modal-fields" class="modal-form-fields"></div>
<div class="modal-actions">
<button id="btn-shared-form-modal-cancel" class="btn btn-ghost btn-sm" type="button">取消</button>
<button id="btn-shared-form-modal-confirm" class="btn btn-primary btn-sm" type="button">确认</button>
</div>
</div>
</div>
<div id="auto-start-modal" class="modal-overlay" hidden>
<div class="modal-card">
<div class="modal-header">
@@ -1012,6 +1078,7 @@
<input id="input-import-settings-file" type="file" accept=".json,application/json" hidden />
<script src="../managed-alias-utils.js"></script>
<script src="../mail2925-utils.js"></script>
<script src="../paypal-utils.js"></script>
<script src="../icloud-utils.js"></script>
<script src="../mail-provider-utils.js"></script>
<script src="../hotmail-utils.js"></script>
@@ -1020,8 +1087,10 @@
<script src="update-service.js"></script>
<script src="contribution-content-update-service.js"></script>
<script src="account-pool-ui.js"></script>
<script src="form-dialog.js"></script>
<script src="hotmail-manager.js"></script>
<script src="mail-2925-manager.js"></script>
<script src="paypal-manager.js"></script>
<script src="icloud-manager.js"></script>
<script src="luckmail-manager.js"></script>
<script src="ip-proxy-provider-711proxy.js"></script>
+319 -33
View File
@@ -97,6 +97,7 @@ const rowSub2ApiDefaultProxy = document.getElementById('row-sub2api-default-prox
const inputSub2ApiDefaultProxy = document.getElementById('input-sub2api-default-proxy');
const rowIpProxyEnabled = document.getElementById('row-ip-proxy-enabled');
const inputIpProxyEnabled = document.getElementById('input-ip-proxy-enabled');
const btnToggleIpProxySection = document.getElementById('btn-toggle-ip-proxy-section');
const ipProxyEnabledStatus = document.getElementById('ip-proxy-enabled-status');
const ipProxyEnabledStatusDot = document.getElementById('ip-proxy-enabled-status-dot');
const ipProxyEnabledStatusText = document.getElementById('ip-proxy-enabled-status-text');
@@ -157,10 +158,9 @@ const inputCodex2ApiAdminKey = document.getElementById('input-codex2api-admin-ke
const rowCustomPassword = document.getElementById('row-custom-password');
const rowPlusMode = document.getElementById('row-plus-mode');
const inputPlusModeEnabled = document.getElementById('input-plus-mode-enabled');
const rowPaypalEmail = document.getElementById('row-paypal-email');
const inputPaypalEmail = document.getElementById('input-paypal-email');
const rowPaypalPassword = document.getElementById('row-paypal-password');
const inputPaypalPassword = document.getElementById('input-paypal-password');
const rowPayPalAccount = document.getElementById('row-paypal-account');
const selectPayPalAccount = document.getElementById('select-paypal-account');
const btnAddPayPalAccount = document.getElementById('btn-add-paypal-account');
const selectMailProvider = document.getElementById('select-mail-provider');
const btnMailLogin = document.getElementById('btn-mail-login');
const rowCustomMailProviderPool = document.getElementById('row-custom-mail-provider-pool');
@@ -187,9 +187,13 @@ const selectTempEmailDomain = document.getElementById('select-temp-email-domain'
const inputTempEmailDomain = document.getElementById('input-temp-email-domain');
const btnTempEmailDomainMode = document.getElementById('btn-temp-email-domain-mode');
const cloudflareTempEmailSection = document.getElementById('cloudflare-temp-email-section');
const btnToggleCloudflareTempEmailSection = document.getElementById('btn-toggle-cloudflare-temp-email-section');
const cloudflareTempEmailSectionBody = document.getElementById('cloudflare-temp-email-section-body');
const btnCloudflareTempEmailUsageGuide = document.getElementById('btn-cloudflare-temp-email-usage-guide');
const btnCloudflareTempEmailGithub = document.getElementById('btn-cloudflare-temp-email-github');
const hotmailSection = document.getElementById('hotmail-section');
const btnToggleHotmailSection = document.getElementById('btn-toggle-hotmail-section');
const hotmailSectionBody = document.getElementById('hotmail-section-body');
const mail2925Section = document.getElementById('mail2925-section');
const luckmailSection = document.getElementById('luckmail-section');
const icloudSection = document.getElementById('icloud-section');
@@ -292,13 +296,23 @@ const rowPhoneVerificationEnabled = document.getElementById('row-phone-verificat
const inputPhoneVerificationEnabled = document.getElementById('input-phone-verification-enabled');
const rowHeroSmsPlatform = document.getElementById('row-hero-sms-platform');
const rowHeroSmsCountry = document.getElementById('row-hero-sms-country');
const rowHeroSmsMaxPrice = document.getElementById('row-hero-sms-max-price');
const rowHeroSmsApiKey = document.getElementById('row-hero-sms-api-key');
const inputHeroSmsApiKey = document.getElementById('input-hero-sms-api-key');
const inputHeroSmsMaxPrice = document.getElementById('input-hero-sms-max-price');
const selectHeroSmsCountry = document.getElementById('select-hero-sms-country');
const displayHeroSmsPlatform = document.getElementById('display-hero-sms-platform');
const rowAccountRunHistoryHelperBaseUrl = document.getElementById('row-account-run-history-helper-base-url');
const inputAccountRunHistoryHelperBaseUrl = document.getElementById('input-account-run-history-helper-base-url');
const autoStartModal = document.getElementById('auto-start-modal');
const sharedFormModal = document.getElementById('shared-form-modal');
const sharedFormModalTitle = document.getElementById('shared-form-modal-title');
const btnSharedFormModalClose = document.getElementById('btn-shared-form-modal-close');
const sharedFormModalMessage = document.getElementById('shared-form-modal-message');
const sharedFormModalAlert = document.getElementById('shared-form-modal-alert');
const sharedFormModalFields = document.getElementById('shared-form-modal-fields');
const btnSharedFormModalCancel = document.getElementById('btn-shared-form-modal-cancel');
const btnSharedFormModalConfirm = document.getElementById('btn-shared-form-modal-confirm');
const autoStartTitle = autoStartModal?.querySelector('.modal-title');
const autoStartMessage = document.getElementById('auto-start-message');
const autoStartAlert = document.getElementById('auto-start-alert');
@@ -362,8 +376,10 @@ const AUTO_RUN_PLUS_RISK_WARNING_MAX_SAFE_RUNS = 3;
const PLUS_CONTRIBUTION_PROMPT_THRESHOLD = 5;
const PLUS_CONTRIBUTION_ACCOUNT_CREDIT = 5;
const PLUS_CONTRIBUTION_DONATION_CREDIT = 20;
const CLOUDFLARE_TEMP_EMAIL_SECTION_EXPANDED_STORAGE_KEY = 'multipage-cloudflare-temp-email-section-expanded';
const HOTMAIL_SERVICE_MODE_REMOTE = 'remote';
const HOTMAIL_SERVICE_MODE_LOCAL = 'local';
const HOTMAIL_SECTION_EXPANDED_STORAGE_KEY = 'multipage-hotmail-section-expanded';
const ICLOUD_PROVIDER = 'icloud';
const GMAIL_PROVIDER = 'gmail';
const GMAIL_ALIAS_GENERATOR = 'gmail-alias';
@@ -641,6 +657,8 @@ let settingsAutoSaveTimer = null;
let settingsSaveRevision = 0;
let cloudflareDomainEditMode = false;
let cloudflareTempEmailDomainEditMode = false;
let cloudflareTempEmailSectionExpanded = false;
let hotmailSectionExpanded = false;
let modalChoiceResolver = null;
let currentModalActions = [];
let modalResultBuilder = null;
@@ -661,6 +679,7 @@ const upsertHotmailAccountInList = window.HotmailUtils?.upsertHotmailAccountInLi
const filterHotmailAccountsByUsage = window.HotmailUtils?.filterHotmailAccountsByUsage;
const getHotmailBulkActionLabel = window.HotmailUtils?.getHotmailBulkActionLabel;
const getHotmailListToggleLabel = window.HotmailUtils?.getHotmailListToggleLabel;
const upsertPayPalAccountInList = window.PayPalUtils?.upsertPayPalAccountInList;
const normalizeLuckmailTimestampValue = window.LuckMailUtils?.normalizeTimestamp
|| ((value) => {
const timestamp = Date.parse(String(value || ''));
@@ -668,6 +687,16 @@ const normalizeLuckmailTimestampValue = window.LuckMailUtils?.normalizeTimestamp
});
const sidepanelUpdateService = window.SidepanelUpdateService;
const contributionContentService = window.SidepanelContributionContentService;
const sharedFormDialog = window.SidepanelFormDialog?.createFormDialog?.({
overlay: sharedFormModal,
titleNode: sharedFormModalTitle,
closeButton: btnSharedFormModalClose,
messageNode: sharedFormModalMessage,
alertNode: sharedFormModalAlert,
fieldsContainer: sharedFormModalFields,
cancelButton: btnSharedFormModalCancel,
confirmButton: btnSharedFormModalConfirm,
});
const DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME = window.LuckMailUtils?.DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME || '保留';
const normalizeIcloudHost = window.IcloudUtils?.normalizeIcloudHost
|| ((value) => {
@@ -747,8 +776,8 @@ const MAIL_PROVIDER_LOGIN_CONFIGS = {
const IP_PROXY_SERVICE_LOGIN_CONFIGS = {
'711proxy': {
label: '711Proxy',
url: 'https://www.711proxy.com/',
buttonLabel: '登录',
url: 'https://www.711proxy.com/signup?code=AD2497',
buttonLabel: '注册',
},
};
@@ -793,6 +822,7 @@ function parseGmailBaseEmail(rawValue = '') {
const value = String(rawValue || '').trim().toLowerCase();
const match = value.match(/^([^@\s+]+)@((?:gmail|googlemail)\.com)$/i);
if (!match) return null;
return {
localPart: match[1],
domain: match[2].toLowerCase(),
@@ -2200,12 +2230,21 @@ function collectSettingsPayload() {
const heroSmsApiKeyValue = typeof inputHeroSmsApiKey !== 'undefined' && inputHeroSmsApiKey
? (inputHeroSmsApiKey.value || '')
: '';
const heroSmsMaxPriceValue = typeof inputHeroSmsMaxPrice !== 'undefined' && inputHeroSmsMaxPrice
? normalizeHeroSmsMaxPriceValue(inputHeroSmsMaxPrice.value)
: '';
const heroSmsCountry = typeof getSelectedHeroSmsCountryOption === 'function'
? getSelectedHeroSmsCountryOption()
: {
id: typeof DEFAULT_HERO_SMS_COUNTRY_ID !== 'undefined' ? DEFAULT_HERO_SMS_COUNTRY_ID : 52,
label: typeof DEFAULT_HERO_SMS_COUNTRY_LABEL !== 'undefined' ? DEFAULT_HERO_SMS_COUNTRY_LABEL : 'Thailand',
};
const payPalAccounts = typeof getPayPalAccounts === 'function'
? getPayPalAccounts(latestState)
: (Array.isArray(latestState?.paypalAccounts) ? latestState.paypalAccounts : []);
const currentPayPalAccount = typeof getCurrentPayPalAccount === 'function'
? getCurrentPayPalAccount(latestState)
: payPalAccounts.find((account) => account?.id === String(latestState?.currentPayPalAccountId || '').trim()) || null;
return {
...(contributionModeEnabled ? {} : {
panelMode: selectPanelMode.value,
@@ -2238,12 +2277,10 @@ function collectSettingsPayload() {
plusModeEnabled: typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled
? Boolean(inputPlusModeEnabled.checked)
: Boolean(latestState?.plusModeEnabled),
paypalEmail: typeof inputPaypalEmail !== 'undefined' && inputPaypalEmail
? inputPaypalEmail.value.trim()
: String(latestState?.paypalEmail || ''),
paypalPassword: typeof inputPaypalPassword !== 'undefined' && inputPaypalPassword
? inputPaypalPassword.value
: String(latestState?.paypalPassword || ''),
paypalEmail: String(currentPayPalAccount?.email || latestState?.paypalEmail || '').trim(),
paypalPassword: String(currentPayPalAccount?.password || latestState?.paypalPassword || ''),
currentPayPalAccountId: String(latestState?.currentPayPalAccountId || '').trim(),
paypalAccounts: payPalAccounts,
...(contributionModeEnabled ? {} : {
customPassword: inputPassword.value,
}),
@@ -2299,6 +2336,7 @@ function collectSettingsPayload() {
DEFAULT_VERIFICATION_RESEND_COUNT
),
heroSmsApiKey: heroSmsApiKeyValue,
heroSmsMaxPrice: heroSmsMaxPriceValue,
heroSmsCountryId: heroSmsCountry.id,
heroSmsCountryLabel: heroSmsCountry.label,
};
@@ -2357,6 +2395,18 @@ function normalizeHeroSmsCountryLabel(value = '') {
return String(value || '').trim() || DEFAULT_HERO_SMS_COUNTRY_LABEL;
}
function normalizeHeroSmsMaxPriceValue(value, fallback = '') {
const trimmed = String(value ?? '').trim();
if (!trimmed) {
return String(fallback || '').trim();
}
const numeric = Number(trimmed);
if (!Number.isFinite(numeric) || numeric <= 0) {
return String(fallback || '').trim();
}
return String(numeric);
}
function getSelectedHeroSmsCountryOption() {
if (!selectHeroSmsCountry) {
return {
@@ -2462,6 +2512,111 @@ function setHotmailServiceMode(mode) {
});
}
function readCloudflareTempEmailSectionExpanded() {
try {
return localStorage.getItem(CLOUDFLARE_TEMP_EMAIL_SECTION_EXPANDED_STORAGE_KEY) === '1';
} catch (err) {
return false;
}
}
function persistCloudflareTempEmailSectionExpanded(expanded) {
try {
if (expanded) {
localStorage.setItem(CLOUDFLARE_TEMP_EMAIL_SECTION_EXPANDED_STORAGE_KEY, '1');
} else {
localStorage.removeItem(CLOUDFLARE_TEMP_EMAIL_SECTION_EXPANDED_STORAGE_KEY);
}
} catch (err) {
// Keep the current session state even if storage is unavailable.
}
}
function isCloudflareTempEmailSectionVisible() {
return Boolean(cloudflareTempEmailSection && cloudflareTempEmailSection.style.display !== 'none');
}
function updateCloudflareTempEmailSectionExpandedUI() {
const expanded = isCloudflareTempEmailSectionVisible() && cloudflareTempEmailSectionExpanded;
if (cloudflareTempEmailSectionBody) {
cloudflareTempEmailSectionBody.hidden = !expanded;
}
if (btnToggleCloudflareTempEmailSection) {
btnToggleCloudflareTempEmailSection.textContent = expanded ? '收起设置' : '展开设置';
btnToggleCloudflareTempEmailSection.title = expanded
? '收起 Cloudflare Temp Email 设置'
: '展开 Cloudflare Temp Email 设置';
btnToggleCloudflareTempEmailSection.setAttribute('aria-expanded', String(expanded));
}
}
function setCloudflareTempEmailSectionExpanded(expanded, options = {}) {
const { persist = true } = options;
cloudflareTempEmailSectionExpanded = Boolean(expanded);
updateCloudflareTempEmailSectionExpandedUI();
if (persist) {
persistCloudflareTempEmailSectionExpanded(cloudflareTempEmailSectionExpanded);
}
}
function toggleCloudflareTempEmailSectionExpanded() {
setCloudflareTempEmailSectionExpanded(!cloudflareTempEmailSectionExpanded);
}
function initCloudflareTempEmailSectionExpandedState() {
setCloudflareTempEmailSectionExpanded(readCloudflareTempEmailSectionExpanded(), { persist: false });
}
function readHotmailSectionExpanded() {
try {
return localStorage.getItem(HOTMAIL_SECTION_EXPANDED_STORAGE_KEY) === '1';
} catch (err) {
return false;
}
}
function persistHotmailSectionExpanded(expanded) {
try {
if (expanded) {
localStorage.setItem(HOTMAIL_SECTION_EXPANDED_STORAGE_KEY, '1');
} else {
localStorage.removeItem(HOTMAIL_SECTION_EXPANDED_STORAGE_KEY);
}
} catch (err) {
// Keep the current session state even if storage is unavailable.
}
}
function updateHotmailSectionExpandedUI() {
const useHotmail = selectMailProvider?.value === 'hotmail-api';
const expanded = useHotmail && hotmailSectionExpanded;
if (hotmailSectionBody) {
hotmailSectionBody.hidden = !expanded;
}
if (btnToggleHotmailSection) {
btnToggleHotmailSection.textContent = expanded ? '收起设置' : '展开设置';
btnToggleHotmailSection.title = expanded ? '收起 Hotmail 账号池设置' : '展开 Hotmail 账号池设置';
btnToggleHotmailSection.setAttribute('aria-expanded', String(expanded));
}
}
function setHotmailSectionExpanded(expanded, options = {}) {
const { persist = true } = options;
hotmailSectionExpanded = Boolean(expanded);
updateHotmailSectionExpandedUI();
if (persist) {
persistHotmailSectionExpanded(hotmailSectionExpanded);
}
}
function toggleHotmailSectionExpanded() {
setHotmailSectionExpanded(!hotmailSectionExpanded);
}
function initHotmailSectionExpandedState() {
setHotmailSectionExpanded(readHotmailSectionExpanded(), { persist: false });
}
function updateAccountRunHistorySettingsUI() {
if (!rowAccountRunHistoryHelperBaseUrl) {
return;
@@ -2472,7 +2627,7 @@ function updateAccountRunHistorySettingsUI() {
function updatePhoneVerificationSettingsUI() {
const enabled = Boolean(inputPhoneVerificationEnabled?.checked);
[rowHeroSmsPlatform, rowHeroSmsCountry, rowHeroSmsApiKey].forEach((row) => {
[rowHeroSmsPlatform, rowHeroSmsCountry, rowHeroSmsMaxPrice, rowHeroSmsApiKey].forEach((row) => {
if (!row) {
return;
}
@@ -2485,8 +2640,7 @@ function updatePlusModeUI() {
? Boolean(inputPlusModeEnabled.checked)
: false;
[
typeof rowPaypalEmail !== 'undefined' ? rowPaypalEmail : null,
typeof rowPaypalPassword !== 'undefined' ? rowPaypalPassword : null,
typeof rowPayPalAccount !== 'undefined' ? rowPayPalAccount : null,
].forEach((row) => {
if (!row) {
return;
@@ -2793,12 +2947,6 @@ function applySettingsState(state) {
if (typeof inputPlusModeEnabled !== 'undefined' && inputPlusModeEnabled) {
inputPlusModeEnabled.checked = Boolean(state?.plusModeEnabled);
}
if (typeof inputPaypalEmail !== 'undefined' && inputPaypalEmail) {
inputPaypalEmail.value = state?.paypalEmail || '';
}
if (typeof inputPaypalPassword !== 'undefined' && inputPaypalPassword) {
inputPaypalPassword.value = state?.paypalPassword || '';
}
inputVpsUrl.value = state?.vpsUrl || '';
inputVpsPassword.value = state?.vpsPassword || '';
setLocalCpaStep9Mode(state?.localCpaStep9Mode);
@@ -2983,6 +3131,9 @@ function applySettingsState(state) {
if (inputHeroSmsApiKey) {
inputHeroSmsApiKey.value = state?.heroSmsApiKey || '';
}
if (inputHeroSmsMaxPrice) {
inputHeroSmsMaxPrice.value = normalizeHeroSmsMaxPriceValue(state?.heroSmsMaxPrice);
}
if (selectHeroSmsCountry) {
const restoredCountryId = String(normalizeHeroSmsCountryId(state?.heroSmsCountryId));
if (Array.from(selectHeroSmsCountry.options).some((option) => option.value === restoredCountryId)) {
@@ -3000,6 +3151,9 @@ function applySettingsState(state) {
updateFallbackThreadIntervalInputState();
updateAccountRunHistorySettingsUI();
updatePhoneVerificationSettingsUI();
if (typeof renderPayPalAccounts === 'function') {
renderPayPalAccounts();
}
if (typeof updatePlusModeUI === 'function') {
updatePlusModeUI();
}
@@ -3664,6 +3818,15 @@ function getCurrentMail2925Email(state = latestState) {
return String(getCurrentMail2925Account(state)?.email || '').trim();
}
function getPayPalAccounts(state = latestState) {
return Array.isArray(state?.paypalAccounts) ? state.paypalAccounts : [];
}
function getCurrentPayPalAccount(state = latestState) {
const currentId = String(state?.currentPayPalAccountId || '').trim();
return getPayPalAccounts(state).find((account) => account.id === currentId) || null;
}
function syncMail2925BaseEmailFromCurrentAccount(state = latestState, options = {}) {
const { persist = false } = options;
if (!isMail2925AccountPoolEnabled(state)) {
@@ -3844,9 +4007,10 @@ function updateIpProxyServiceLoginButtonState(options = {}) {
? Boolean(options.enabled)
: Boolean(getSelectedIpProxyEnabled());
btnIpProxyServiceLogin.disabled = !enabled || !loginUrl;
btnIpProxyServiceLogin.textContent = loginConfig?.buttonLabel || '登录';
const buttonLabel = loginConfig?.buttonLabel || '登录';
btnIpProxyServiceLogin.textContent = buttonLabel;
btnIpProxyServiceLogin.title = loginUrl
? `打开 ${loginConfig?.label || service} 登录`
? `打开 ${loginConfig?.label || service} ${buttonLabel}`
: '当前代理服务没有可跳转的登录页';
}
@@ -3947,6 +4111,9 @@ function updateMailProviderUI() {
if (cloudflareTempEmailSection) {
cloudflareTempEmailSection.style.display = showCloudflareTempEmailSettings ? '' : 'none';
}
if (typeof updateCloudflareTempEmailSectionExpandedUI === 'function') {
updateCloudflareTempEmailSectionExpandedUI();
}
if (icloudSection) {
const showIcloudSection = (useEmailGenerator && useIcloud) || useIcloudProvider;
icloudSection.style.display = showIcloudSection ? '' : 'none';
@@ -3988,6 +4155,9 @@ function updateMailProviderUI() {
if (hotmailSection) {
hotmailSection.style.display = useHotmail ? '' : 'none';
}
if (typeof updateHotmailSectionExpandedUI === 'function') {
updateHotmailSectionExpandedUI();
}
if (mail2925Section) {
mail2925Section.style.display = useMail2925AccountPool ? '' : 'none';
}
@@ -4495,6 +4665,54 @@ function syncToggleButtonLabel(button, input, labels) {
button.title = isHidden ? labels.show : labels.hide;
}
function getPasswordToggleLabels(button) {
if (!button) {
return {
show: '\u663e\u793a\u5185\u5bb9',
hide: '\u9690\u85cf\u5185\u5bb9',
};
}
const show = button.dataset?.showLabel
|| button.getAttribute('aria-label')
|| button.title
|| '\u663e\u793a\u5185\u5bb9';
const hide = button.dataset?.hideLabel
|| String(show).replace(/^\u663e\u793a/, '\u9690\u85cf')
|| '\u9690\u85cf\u5185\u5bb9';
return { show, hide };
}
function syncPasswordVisibilityToggle(button) {
const targetId = String(button?.dataset?.passwordToggle || '').trim();
const input = targetId ? document.getElementById(targetId) : null;
if (!button || !input) return;
syncToggleButtonLabel(button, input, getPasswordToggleLabels(button));
}
function syncPasswordVisibilityToggles(root = document) {
root.querySelectorAll?.('[data-password-toggle]').forEach(syncPasswordVisibilityToggle);
}
function bindPasswordVisibilityToggles(root = document) {
root.querySelectorAll?.('[data-password-toggle]').forEach((button) => {
if (button.dataset?.passwordToggleBound === 'true') {
syncPasswordVisibilityToggle(button);
return;
}
if (button.dataset) {
button.dataset.passwordToggleBound = 'true';
}
syncPasswordVisibilityToggle(button);
button.addEventListener('click', () => {
const targetId = String(button.dataset?.passwordToggle || '').trim();
const input = targetId ? document.getElementById(targetId) : null;
if (!input) return;
input.type = input.type === 'password' ? 'text' : 'password';
syncPasswordVisibilityToggle(button);
});
});
}
async function copyTextToClipboard(text) {
const value = String(text || '').trim();
if (!value) {
@@ -4563,6 +4781,39 @@ const bindHotmailEvents = hotmailManager?.bindHotmailEvents
|| (() => { });
bindHotmailEvents();
const payPalManager = window.SidepanelPayPalManager?.createPayPalManager({
state: {
getLatestState: () => latestState,
syncLatestState,
},
dom: {
btnAddPayPalAccount,
selectPayPalAccount,
},
helpers: {
escapeHtml,
getPayPalAccounts,
openFormDialog: (options) => {
if (!sharedFormDialog?.open) {
throw new Error('表单弹窗能力未加载,请刷新扩展后重试。');
}
return sharedFormDialog.open(options);
},
showToast,
},
runtime: {
sendMessage: (message) => chrome.runtime.sendMessage(message),
},
paypalUtils: {
upsertPayPalAccountInList,
},
});
const renderPayPalAccounts = payPalManager?.renderPayPalAccounts
|| (() => { });
const bindPayPalEvents = payPalManager?.bindPayPalEvents
|| (() => { });
bindPayPalEvents();
const mail2925Manager = window.SidepanelMail2925Manager?.createMail2925Manager({
state: {
getLatestState: () => latestState,
@@ -5100,6 +5351,28 @@ btnToggleIpProxyPassword?.addEventListener('click', () => {
syncIpProxyPasswordToggleLabel();
});
btnToggleIpProxySection?.addEventListener('click', () => {
if (typeof toggleIpProxySectionExpanded === 'function') {
toggleIpProxySectionExpanded();
}
});
btnToggleCloudflareTempEmailSection?.addEventListener('click', () => {
toggleCloudflareTempEmailSectionExpanded();
});
btnToggleHotmailSection?.addEventListener('click', () => {
toggleHotmailSectionExpanded();
});
btnToggleHotmailForm?.addEventListener('click', () => {
setHotmailSectionExpanded(true);
}, true);
btnToggleHotmailList?.addEventListener('click', () => {
setHotmailSectionExpanded(true);
}, true);
btnMailLogin?.addEventListener('click', async () => {
const config = getMailProviderLoginConfig();
const loginUrl = getMailProviderLoginUrl();
@@ -5524,16 +5797,6 @@ inputPlusModeEnabled?.addEventListener('change', () => {
saveSettings({ silent: true }).catch(() => { });
});
[inputPaypalEmail, inputPaypalPassword].forEach((input) => {
input?.addEventListener('input', () => {
markSettingsDirty(true);
scheduleSettingsAutoSave();
});
input?.addEventListener('blur', () => {
saveSettings({ silent: true }).catch(() => { });
});
});
selectMailProvider.addEventListener('change', async () => {
const previousProvider = latestState?.mailProvider || '';
const previousMail2925Mode = latestState?.mail2925Mode;
@@ -6413,6 +6676,15 @@ inputHeroSmsApiKey?.addEventListener('blur', () => {
saveSettings({ silent: true }).catch(() => { });
});
inputHeroSmsMaxPrice?.addEventListener('input', () => {
markSettingsDirty(true);
scheduleSettingsAutoSave();
});
inputHeroSmsMaxPrice?.addEventListener('blur', () => {
inputHeroSmsMaxPrice.value = normalizeHeroSmsMaxPriceValue(inputHeroSmsMaxPrice.value);
saveSettings({ silent: true }).catch(() => { });
});
selectHeroSmsCountry?.addEventListener('change', () => {
updateHeroSmsPlatformDisplay(getSelectedHeroSmsCountryOption().label);
markSettingsDirty(true);
@@ -6518,6 +6790,7 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
applyAutoRunStatus(currentAutoRun);
updateProgressCounter();
updateButtonStates();
renderPayPalAccounts();
renderHotmailAccounts();
renderMail2925Accounts();
if (isLuckmailProvider()) {
@@ -6720,6 +6993,9 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
inputEmail.value = getCurrentHotmailEmail();
}
}
if (message.payload.currentPayPalAccountId !== undefined || message.payload.paypalAccounts !== undefined) {
renderPayPalAccounts();
}
if (message.payload.currentMail2925AccountId !== undefined || message.payload.mail2925Accounts !== undefined) {
renderMail2925Accounts();
if (selectMailProvider.value === '2925') {
@@ -6811,6 +7087,9 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.payload.heroSmsApiKey !== undefined && inputHeroSmsApiKey) {
inputHeroSmsApiKey.value = message.payload.heroSmsApiKey || '';
}
if (message.payload.heroSmsMaxPrice !== undefined && inputHeroSmsMaxPrice) {
inputHeroSmsMaxPrice.value = normalizeHeroSmsMaxPriceValue(message.payload.heroSmsMaxPrice);
}
if (message.payload.phoneVerificationEnabled !== undefined && inputPhoneVerificationEnabled) {
inputPhoneVerificationEnabled.checked = Boolean(message.payload.phoneVerificationEnabled);
updatePhoneVerificationSettingsUI();
@@ -6925,9 +7204,15 @@ document.addEventListener('scroll', () => {
// ============================================================
initializeManualStepActions();
bindPasswordVisibilityToggles();
initTheme();
initHotmailListExpandedState();
initMail2925ListExpandedState();
initCloudflareTempEmailSectionExpandedState();
initHotmailSectionExpandedState();
if (typeof initIpProxySectionExpandedState === 'function') {
initIpProxySectionExpandedState();
}
updateSaveButtonState();
updateConfigMenuControls();
setLocalCpaStep9Mode(DEFAULT_LOCAL_CPA_STEP9_MODE);
@@ -6945,6 +7230,7 @@ loadHeroSmsCountries().catch((err) => {
syncIpProxyApiUrlToggleLabel();
syncIpProxyUsernameToggleLabel();
syncIpProxyPasswordToggleLabel();
syncPasswordVisibilityToggles();
updatePanelModeUI();
updateButtonStates();
updateStatusDisplay(latestState);
@@ -114,6 +114,12 @@ let currentState = {
inbucketMailbox: '',
cloudflareDomain: '',
cloudflareDomains: [],
reusablePhoneActivation: {
activationId: '123456',
phoneNumber: '66959916439',
successfulUses: 1,
maxUses: 3,
},
tabRegistry: {},
sourceLastUrls: {},
};
@@ -329,6 +335,16 @@ return {
assert.strictEqual(snapshot.currentState.autoRunSessionId, 0, 'session id should be cleared after completion');
assert.strictEqual(snapshot.currentState.gmailBaseEmail, 'demo@gmail.com', 'gmail base email should survive fresh-attempt reset');
assert.strictEqual(snapshot.currentState.mail2925BaseEmail, 'demo@2925.com', '2925 base email should survive fresh-attempt reset');
assert.deepStrictEqual(
snapshot.currentState.reusablePhoneActivation,
{
activationId: '123456',
phoneNumber: '66959916439',
successfulUses: 1,
maxUses: 3,
},
'reusable phone activation should survive fresh-attempt reset'
);
console.log('auto-run fresh attempt reset tests passed');
})().catch((error) => {
+164
View File
@@ -0,0 +1,164 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background/auto-run-controller.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundAutoRunController;`)(globalScope);
test('auto-run controller verifies hotmail mailbox before each fresh attempt starts', async () => {
const events = {
order: [],
preflightCalls: [],
runCalls: 0,
};
let currentState = {
stepStatuses: {},
vpsUrl: 'https://example.com/vps',
vpsPassword: 'secret',
customPassword: '',
autoRunSkipFailures: false,
autoRunFallbackThreadIntervalMinutes: 0,
autoRunDelayEnabled: false,
autoRunDelayMinutes: 30,
autoStepDelaySeconds: null,
mailProvider: 'hotmail-api',
emailGenerator: 'duck',
gmailBaseEmail: '',
mail2925BaseEmail: '',
emailPrefix: 'demo',
inbucketHost: '',
inbucketMailbox: '',
cloudflareDomain: '',
cloudflareDomains: [],
tabRegistry: {},
sourceLastUrls: {},
autoRunRoundSummaries: [],
};
const runtime = {
state: {
autoRunActive: false,
autoRunCurrentRun: 0,
autoRunTotalRuns: 1,
autoRunAttemptRun: 0,
autoRunSessionId: 0,
},
get() {
return { ...this.state };
},
set(updates = {}) {
this.state = { ...this.state, ...updates };
},
};
let sessionSeed = 0;
const controller = api.createAutoRunController({
addLog: async () => {},
appendAccountRunRecord: async () => null,
AUTO_RUN_MAX_RETRIES_PER_ROUND: 3,
AUTO_RUN_RETRY_DELAY_MS: 3000,
AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry',
AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds',
broadcastAutoRunStatus: async (phase, payload = {}) => {
currentState = {
...currentState,
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
autoRunPhase: phase,
autoRunCurrentRun: payload.currentRun ?? runtime.state.autoRunCurrentRun,
autoRunTotalRuns: payload.totalRuns ?? runtime.state.autoRunTotalRuns,
autoRunAttemptRun: payload.attemptRun ?? runtime.state.autoRunAttemptRun,
autoRunSessionId: payload.sessionId ?? runtime.state.autoRunSessionId,
};
},
broadcastStopToContentScripts: async () => {},
cancelPendingCommands: () => {},
clearStopRequest: () => {},
createAutoRunSessionId: () => {
sessionSeed += 1;
return sessionSeed;
},
ensureHotmailMailboxReadyForAutoRunRound: async (payload = {}) => {
events.order.push('preflight');
events.preflightCalls.push({ ...payload });
},
getAutoRunStatusPayload: (phase, payload = {}) => ({
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
autoRunPhase: phase,
autoRunCurrentRun: payload.currentRun ?? 0,
autoRunTotalRuns: payload.totalRuns ?? 1,
autoRunAttemptRun: payload.attemptRun ?? 0,
autoRunSessionId: payload.sessionId ?? 0,
}),
getErrorMessage: (error) => error?.message || String(error || ''),
getFirstUnfinishedStep: () => 1,
getPendingAutoRunTimerPlan: () => null,
getRunningSteps: () => [],
getState: async () => ({
...currentState,
stepStatuses: { ...(currentState.stepStatuses || {}) },
tabRegistry: { ...(currentState.tabRegistry || {}) },
sourceLastUrls: { ...(currentState.sourceLastUrls || {}) },
}),
getStopRequested: () => false,
hasSavedProgress: () => false,
isAddPhoneAuthFailure: () => false,
isRestartCurrentAttemptError: () => false,
isSignupUserAlreadyExistsFailure: () => false,
isStopError: (error) => (error?.message || String(error || '')) === '流程已被用户停止。',
launchAutoRunTimerPlan: async () => false,
normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Math.floor(Number(value) || 0)),
persistAutoRunTimerPlan: async () => ({}),
resetState: async () => {
currentState = {
...currentState,
stepStatuses: {},
tabRegistry: {},
sourceLastUrls: {},
};
},
runAutoSequenceFromStep: async () => {
events.order.push('run');
events.runCalls += 1;
},
runtime,
setState: async (updates = {}) => {
currentState = {
...currentState,
...updates,
stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses,
tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry,
sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls,
};
},
sleepWithStop: async () => {},
throwIfAutoRunSessionStopped: (sessionId) => {
if (sessionId && sessionId !== runtime.state.autoRunSessionId) {
throw new Error('流程已被用户停止。');
}
},
waitForRunningStepsToFinish: async () => currentState,
chrome: {
runtime: {
sendMessage() {
return Promise.resolve();
},
},
},
});
await controller.autoRunLoop(1, {
autoRunSkipFailures: false,
mode: 'restart',
});
assert.equal(events.runCalls, 1);
assert.equal(events.preflightCalls.length, 1);
assert.deepEqual(events.order, ['preflight', 'run']);
assert.match(
JSON.stringify(events.preflightCalls[0]),
/"targetRun":1/
);
});
+1
View File
@@ -713,6 +713,7 @@ function broadcastDataUpdate() {}
function isLocalhostOAuthCallbackUrl() {
return true;
}
async function finalizePhoneActivationAfterSuccessfulFlow() {}
async function finalizeIcloudAliasAfterSuccessfulFlow() {}
${bundle}
@@ -68,7 +68,7 @@ test('ensureMail2925MailboxSession reuses current mailbox page without sending l
});
assert.equal(sendCalls, 0);
assert.equal(readyCalls, 0);
assert.equal(readyCalls, 1);
assert.equal(result.result.usedExistingSession, true);
});
@@ -12,6 +12,7 @@ function createRouter(overrides = {}) {
stepStatuses: [],
emailStates: [],
finalizePayloads: [],
phoneFinalizations: [],
notifyCompletions: [],
notifyErrors: [],
securityBlocks: [],
@@ -49,10 +50,13 @@ function createRouter(overrides = {}) {
executeStepViaCompletionSignal: async () => {},
exportSettingsBundle: async () => ({}),
fetchGeneratedEmail: async () => '',
finalizePhoneActivationAfterSuccessfulFlow: overrides.finalizePhoneActivationAfterSuccessfulFlow || (async (state) => {
events.phoneFinalizations.push(state);
}),
finalizeStep3Completion: overrides.finalizeStep3Completion || (async (payload) => {
events.finalizePayloads.push(payload);
}),
finalizeIcloudAliasAfterSuccessfulFlow: async () => {},
finalizeIcloudAliasAfterSuccessfulFlow: overrides.finalizeIcloudAliasAfterSuccessfulFlow || (async () => {}),
findHotmailAccount: async () => null,
flushCommand: async () => {},
getCurrentLuckmailPurchase: () => null,
@@ -263,6 +267,68 @@ test('message router finalizes step 3 before marking it completed', async () =>
assert.deepStrictEqual(response, { ok: true });
});
test('message router finalizes pending phone activation on platform verify success', async () => {
const state = {
stepStatuses: { 10: 'pending' },
reusablePhoneActivation: {
activationId: '123456',
phoneNumber: '66959916439',
successfulUses: 0,
maxUses: 3,
},
pendingPhoneActivationConfirmation: {
activationId: '123456',
phoneNumber: '66959916439',
successfulUses: 0,
maxUses: 3,
},
};
const { router, events } = createRouter({
state,
getStepDefinitionForState: (step) => ({ id: step, key: step === 10 ? 'platform-verify' : '' }),
});
await router.handleStepData(10, {
localhostUrl: 'http://localhost:1455/auth/callback?code=ok',
});
assert.deepStrictEqual(events.phoneFinalizations, [state]);
});
test('message router does not finalize pending phone activation when icloud finalization fails', async () => {
const state = {
stepStatuses: { 10: 'pending' },
reusablePhoneActivation: {
activationId: '123456',
phoneNumber: '66959916439',
successfulUses: 0,
maxUses: 3,
},
pendingPhoneActivationConfirmation: {
activationId: '123456',
phoneNumber: '66959916439',
successfulUses: 0,
maxUses: 3,
},
};
const { router, events } = createRouter({
state,
getStepDefinitionForState: (step) => ({ id: step, key: step === 10 ? 'platform-verify' : '' }),
finalizeIcloudAliasAfterSuccessfulFlow: async () => {
throw new Error('icloud finalize failed');
},
});
await assert.rejects(
() => router.handleStepData(10, {
localhostUrl: 'http://localhost:1455/auth/callback?code=ok',
}),
/icloud finalize failed/
);
assert.deepStrictEqual(events.phoneFinalizations, []);
});
test('message router marks step 3 failed when post-submit finalize fails', async () => {
const { router, events } = createRouter({
finalizeStep3Completion: async () => {
@@ -0,0 +1,91 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
test('background imports paypal account store module', () => {
const source = fs.readFileSync('background.js', 'utf8');
assert.match(source, /background\/paypal-account-store\.js/);
assert.match(source, /paypal-utils\.js/);
});
test('paypal account store module exposes a factory', () => {
const source = fs.readFileSync('background/paypal-account-store.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundPayPalAccountStore;`)(globalScope);
assert.equal(typeof api?.createPayPalAccountStore, 'function');
});
test('paypal account store selects account and keeps legacy paypal credentials in sync', async () => {
const source = fs.readFileSync('background/paypal-account-store.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundPayPalAccountStore;`)(globalScope);
let latestState = {
paypalAccounts: [],
currentPayPalAccountId: '',
paypalEmail: '',
paypalPassword: '',
};
const broadcasts = [];
const store = api.createPayPalAccountStore({
broadcastDataUpdate(payload) {
broadcasts.push(payload);
},
findPayPalAccount(accounts, accountId) {
return (Array.isArray(accounts) ? accounts : []).find((account) => account.id === accountId) || null;
},
getState: async () => latestState,
normalizePayPalAccount(account = {}) {
return {
id: String(account.id || 'generated'),
email: String(account.email || '').trim().toLowerCase(),
password: String(account.password || ''),
createdAt: Number(account.createdAt) || 1,
updatedAt: Number(account.updatedAt) || 1,
lastUsedAt: Number(account.lastUsedAt) || 0,
};
},
normalizePayPalAccounts(accounts) {
return Array.isArray(accounts) ? accounts.slice() : [];
},
setPersistentSettings: async (updates) => {
latestState = { ...latestState, ...updates };
},
setState: async (updates) => {
latestState = { ...latestState, ...updates };
},
upsertPayPalAccountInList(accounts, nextAccount) {
const list = Array.isArray(accounts) ? accounts.slice() : [];
const existingIndex = list.findIndex((account) => account.id === nextAccount.id);
if (existingIndex >= 0) {
list[existingIndex] = nextAccount;
return list;
}
list.push(nextAccount);
return list;
},
});
const account = await store.upsertPayPalAccount({
id: 'pp-1',
email: 'User@Example.com',
password: 'secret',
});
assert.equal(account.email, 'user@example.com');
const selected = await store.setCurrentPayPalAccount('pp-1');
assert.equal(selected.id, 'pp-1');
assert.equal(latestState.currentPayPalAccountId, 'pp-1');
assert.equal(latestState.paypalEmail, 'user@example.com');
assert.equal(latestState.paypalPassword, 'secret');
assert.deepStrictEqual(
broadcasts.at(-1),
{
currentPayPalAccountId: 'pp-1',
paypalEmail: 'user@example.com',
paypalPassword: 'secret',
}
);
});
+72
View File
@@ -310,6 +310,78 @@ test('step 8 retries in-place when polling fails but auth page still stays on ve
assert.equal(events.ensureCalls >= 3, true);
});
test('step 8 keeps resend cooldown timestamp across in-place retries to avoid repeated resend storms', async () => {
const events = {
resolveCalls: 0,
resolveLastResendAts: [],
sleepMs: [],
};
const realDateNow = Date.now;
Date.now = () => 230000;
const executor = api.createStep8Executor({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
confirmCustomVerificationStepBypass: async () => {},
ensureStep8VerificationPageReady: async () => ({ state: 'verification_page', displayedEmail: 'user@example.com' }),
rerunStep7ForStep8Recovery: async () => {},
getOAuthFlowRemainingMs: async () => 9000,
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 9000),
getMailConfig: () => ({
provider: 'qq',
label: 'QQ 邮箱',
source: 'mail-qq',
url: 'https://mail.qq.com',
navigateOnReuse: false,
}),
getState: async () => ({ email: 'user@example.com', password: 'secret', loginVerificationRequestedAt: null }),
getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2),
HOTMAIL_PROVIDER: 'hotmail-api',
isTabAlive: async () => true,
isVerificationMailPollingError: (error) => /页面通信异常|did not respond/i.test(String(error?.message || error || '')),
LUCKMAIL_PROVIDER: 'luckmail-api',
resolveVerificationStep: async (_step, _state, _mail, options) => {
events.resolveCalls += 1;
events.resolveLastResendAts.push(Number(options?.lastResendAt) || 0);
if (events.resolveCalls === 1) {
await options.onResendRequestedAt(222000);
throw new Error('步骤 8:页面通信异常 did not respond in 1s');
}
},
reuseOrCreateTab: async () => {},
setState: async () => {},
setStepStatus: async () => {},
shouldUseCustomRegistrationEmail: () => false,
sleepWithStop: async (ms) => {
events.sleepMs.push(ms);
},
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
throwIfStopped: () => {},
});
try {
await executor.executeStep8({
email: 'user@example.com',
password: 'secret',
oauthUrl: 'https://oauth.example/latest',
});
} finally {
Date.now = realDateNow;
}
assert.equal(events.resolveCalls, 2);
assert.deepStrictEqual(events.resolveLastResendAts, [0, 222000]);
assert.equal(events.sleepMs.length >= 1, true);
assert.equal(events.sleepMs[0], 3000);
});
test('step 8 completes when polling fails but recovery probe shows oauth consent page', async () => {
const events = {
ensureCalls: 0,
+314
View File
@@ -0,0 +1,314 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const { pickHotmailAccountForRun } = require('../hotmail-utils.js');
const source = fs.readFileSync('background.js', 'utf8');
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
const start = markers
.map((marker) => source.indexOf(marker))
.find((index) => index >= 0);
if (start < 0) {
return '';
}
let parenDepth = 0;
let signatureEnded = false;
let braceStart = -1;
for (let index = start; index < source.length; index += 1) {
const ch = source[index];
if (ch === '(') {
parenDepth += 1;
} else if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) {
signatureEnded = true;
}
} else if (ch === '{' && signatureEnded) {
braceStart = index;
break;
}
}
if (braceStart < 0) {
return '';
}
let depth = 0;
let end = braceStart;
for (; end < source.length; end += 1) {
const ch = source[end];
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) {
end += 1;
break;
}
}
}
return source.slice(start, end);
}
const isAuthorizedHotmailRunAccountSource = extractFunction('isAuthorizedHotmailRunAccount');
const isPendingHotmailVerificationCandidateSource = extractFunction('isPendingHotmailVerificationCandidate');
const compareHotmailAccountAllocationPrioritySource = extractFunction('compareHotmailAccountAllocationPriority');
const pickPendingHotmailAccountForVerificationSource = extractFunction('pickPendingHotmailAccountForVerification');
const ensureHotmailAccountForFlowSource = extractFunction('ensureHotmailAccountForFlow');
const ensureHotmailMailboxReadyForAutoRunRoundSource = extractFunction('ensureHotmailMailboxReadyForAutoRunRound');
function createHotmailPreflightApi(initialState, verifyImpl = async () => ({ account: null, messageCount: 0 })) {
const factory = new Function('deps', `
let currentState = JSON.parse(JSON.stringify(deps.initialState));
const getState = async () => ({
...currentState,
hotmailAccounts: Array.isArray(currentState.hotmailAccounts)
? currentState.hotmailAccounts.map((account) => ({ ...account }))
: [],
});
const normalizeHotmailAccounts = (accounts) => Array.isArray(accounts)
? accounts.map((account) => ({ ...account }))
: [];
const findHotmailAccount = (accounts, accountId) => normalizeHotmailAccounts(accounts)
.find((account) => account.id === accountId) || null;
const setCurrentHotmailAccount = async (accountId) => {
const state = await getState();
const account = findHotmailAccount(state.hotmailAccounts, accountId);
if (!account) {
throw new Error('missing Hotmail account');
}
currentState = {
...currentState,
currentHotmailAccountId: accountId,
};
return account;
};
const pickHotmailAccountForRun = deps.pickHotmailAccountForRun;
const verifyHotmailAccount = async (accountId) => deps.verifyHotmailAccount(accountId, async () => getState());
const isHotmailProvider = (stateOrProvider) => {
const provider = typeof stateOrProvider === 'string'
? stateOrProvider
: stateOrProvider?.mailProvider;
return provider === 'hotmail-api';
};
const addLog = async (message, level = 'info') => {
deps.logs.push({ message, level });
};
const throwIfStopped = () => {};
${isAuthorizedHotmailRunAccountSource}
${isPendingHotmailVerificationCandidateSource}
${compareHotmailAccountAllocationPrioritySource}
${pickPendingHotmailAccountForVerificationSource}
${ensureHotmailAccountForFlowSource}
${ensureHotmailMailboxReadyForAutoRunRoundSource}
return {
ensureHotmailAccountForFlow,
ensureHotmailMailboxReadyForAutoRunRound: typeof ensureHotmailMailboxReadyForAutoRunRound === 'function'
? ensureHotmailMailboxReadyForAutoRunRound
: undefined,
getState,
};
`);
const logs = [];
return {
api: factory({
initialState,
logs,
pickHotmailAccountForRun,
verifyHotmailAccount: verifyImpl,
}),
logs,
};
}
test('ensureHotmailAccountForFlow skips excluded current hotmail account when allocating a fresh account', async () => {
const { api } = createHotmailPreflightApi({
mailProvider: 'hotmail-api',
currentHotmailAccountId: 'primary',
hotmailAccounts: [
{
id: 'primary',
email: 'primary@hotmail.com',
status: 'authorized',
refreshToken: 'rt-primary',
used: false,
lastUsedAt: 1,
},
{
id: 'backup',
email: 'backup@hotmail.com',
status: 'authorized',
refreshToken: 'rt-backup',
used: false,
lastUsedAt: 2,
},
],
});
const account = await api.ensureHotmailAccountForFlow({
allowAllocate: true,
markUsed: false,
excludeIds: ['primary'],
});
assert.equal(account.id, 'backup');
});
test('ensureHotmailMailboxReadyForAutoRunRound switches to another hotmail account after a verification failure', async () => {
const verifyCalls = [];
const { api, logs } = createHotmailPreflightApi({
mailProvider: 'hotmail-api',
currentHotmailAccountId: 'primary',
hotmailAccounts: [
{
id: 'primary',
email: 'primary@hotmail.com',
status: 'authorized',
refreshToken: 'rt-primary',
used: false,
lastUsedAt: 1,
},
{
id: 'backup',
email: 'backup@hotmail.com',
status: 'authorized',
refreshToken: 'rt-backup',
used: false,
lastUsedAt: 2,
},
],
}, async (accountId, getState) => {
verifyCalls.push(accountId);
const state = await getState();
const account = state.hotmailAccounts.find((item) => item.id === accountId);
if (accountId === 'primary') {
throw new Error('INBOX unavailable');
}
return {
account,
messageCount: 4,
};
});
assert.equal(typeof api.ensureHotmailMailboxReadyForAutoRunRound, 'function');
const account = await Promise.race([
api.ensureHotmailMailboxReadyForAutoRunRound({
targetRun: 1,
totalRuns: 3,
attemptRun: 1,
}),
new Promise((_, reject) => {
setTimeout(() => reject(new Error('Hotmail auto-run preflight timed out')), 200);
}),
]);
const state = await api.getState();
assert.equal(account.id, 'backup');
assert.equal(state.currentHotmailAccountId, 'backup');
assert.deepEqual(verifyCalls, ['primary', 'backup']);
assert.ok(logs.some(({ message }) => /切换下一个 Hotmail 账号/.test(message)));
});
test('ensureHotmailMailboxReadyForAutoRunRound verifies pending hotmail accounts when no authorized account exists yet', async () => {
const verifyCalls = [];
const { api } = createHotmailPreflightApi({
mailProvider: 'hotmail-api',
currentHotmailAccountId: null,
hotmailAccounts: [
{
id: 'pending-1',
email: 'pending-1@hotmail.com',
status: 'pending',
refreshToken: 'rt-pending-1',
used: false,
lastUsedAt: 0,
},
],
}, async (accountId, getState) => {
verifyCalls.push(accountId);
const state = await getState();
const account = state.hotmailAccounts.find((item) => item.id === accountId);
return {
account: {
...account,
status: 'authorized',
},
messageCount: 2,
};
});
const account = await api.ensureHotmailMailboxReadyForAutoRunRound({
targetRun: 1,
totalRuns: 1,
attemptRun: 1,
});
const state = await api.getState();
assert.equal(account.id, 'pending-1');
assert.equal(state.currentHotmailAccountId, 'pending-1');
assert.deepEqual(verifyCalls, ['pending-1']);
});
test('ensureHotmailMailboxReadyForAutoRunRound falls back to pending hotmail accounts after authorized accounts fail', async () => {
const verifyCalls = [];
const { api, logs } = createHotmailPreflightApi({
mailProvider: 'hotmail-api',
currentHotmailAccountId: 'authorized-primary',
hotmailAccounts: [
{
id: 'authorized-primary',
email: 'authorized-primary@hotmail.com',
status: 'authorized',
refreshToken: 'rt-authorized-primary',
used: false,
lastUsedAt: 1,
},
{
id: 'pending-backup',
email: 'pending-backup@hotmail.com',
status: 'pending',
refreshToken: 'rt-pending-backup',
used: false,
lastUsedAt: 2,
},
],
}, async (accountId, getState) => {
verifyCalls.push(accountId);
const state = await getState();
const account = state.hotmailAccounts.find((item) => item.id === accountId);
if (accountId === 'authorized-primary') {
throw new Error('INBOX unavailable');
}
return {
account: {
...account,
status: 'authorized',
},
messageCount: 3,
};
});
const account = await Promise.race([
api.ensureHotmailMailboxReadyForAutoRunRound({
targetRun: 1,
totalRuns: 2,
attemptRun: 1,
}),
new Promise((_, reject) => {
setTimeout(() => reject(new Error('Hotmail auto-run pending fallback timed out')), 200);
}),
]);
const state = await api.getState();
assert.equal(account.id, 'pending-backup');
assert.equal(state.currentHotmailAccountId, 'pending-backup');
assert.deepEqual(verifyCalls, ['authorized-primary', 'pending-backup']);
assert.ok(logs.some(({ message }) => /待校验|未校验/.test(message)));
});
+175
View File
@@ -0,0 +1,175 @@
import importlib.util
import io
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest import mock
def load_hotmail_helper():
module_path = Path(__file__).resolve().parents[1] / "scripts" / "hotmail_helper.py"
spec = importlib.util.spec_from_file_location("hotmail_helper", module_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
hotmail_helper = load_hotmail_helper()
class HotmailHelperLoggingTest(unittest.TestCase):
def test_select_latest_code_can_use_full_body_when_preview_is_truncated(self):
css_prefix = (
'Your temporary ChatGPT verification code '
'@font-face { font-family: "Söhne"; src: url(https://cdn.openai.com/common/fonts/soehne/soehne-buch.woff2) format("woff2"); } '
'.ExternalClass { width: 100%; } '
'#bodyTable { width: 560px; } '
'body { min-width: 100% !important; } '
) * 8
full_body = (
css_prefix
+ 'Enter this temporary verification code to continue: 272964 '
+ 'Please ignore this email if this was not you.'
)
message = {
"id": "imap-1",
"mailbox": "INBOX",
"subject": "Your temporary ChatGPT verification code",
"from": {
"emailAddress": {
"address": "otp@tm1.openai.com",
"name": "OpenAI",
}
},
"bodyPreview": full_body[:500],
"body": {
"content": full_body,
},
"receivedTimestamp": 200,
}
result = hotmail_helper.select_latest_code(
[message],
['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'],
['verify', 'verification', 'code', '验证码', 'confirm', 'login'],
[],
0,
)
self.assertEqual(result["code"], "272964")
self.assertEqual(result["message"]["id"], "imap-1")
def test_log_openai_messages_logs_full_body_when_available(self):
messages = [{
"mailbox": "INBOX",
"subject": "Your verification code",
"from": {
"emailAddress": {
"address": "account-security@openai.com",
"name": "OpenAI",
}
},
"bodyPreview": "Use 123456 to continue.",
"body": {
"content": "Hello there\nUse 123456 to continue.",
},
}]
output = io.StringIO()
with redirect_stdout(output):
hotmail_helper.log_openai_messages(messages, transport="imap")
rendered = output.getvalue()
self.assertIn(
"[HotmailHelper] openai mail received transport=imap mailbox=INBOX sender=account-security@openai.com senderName=OpenAI subject=Your verification code",
rendered,
)
self.assertIn(
"[HotmailHelper] openai mail full body start transport=imap mailbox=INBOX sender=account-security@openai.com senderName=OpenAI subject=Your verification code",
rendered,
)
self.assertIn("Hello there\nUse 123456 to continue.", rendered)
self.assertIn("[HotmailHelper] openai mail full body end", rendered)
def test_log_openai_messages_falls_back_to_preview_without_full_body(self):
messages = [{
"mailbox": "Junk",
"subject": "Verify your sign in",
"from": {
"emailAddress": {
"address": "noreply@tm.openai.com",
"name": "ChatGPT",
}
},
"bodyPreview": "Use 654321 to continue.",
}]
output = io.StringIO()
with redirect_stdout(output):
hotmail_helper.log_openai_messages(messages, transport="graph")
rendered = output.getvalue()
self.assertIn(
"[HotmailHelper] openai mail received transport=graph mailbox=Junk sender=noreply@tm.openai.com senderName=ChatGPT subject=Verify your sign in",
rendered,
)
self.assertIn(
"[HotmailHelper] openai mail preview transport=graph mailbox=Junk sender=noreply@tm.openai.com senderName=ChatGPT subject=Verify your sign in preview=Use 654321 to continue.",
rendered,
)
self.assertNotIn("openai mail full body start", rendered)
def test_refresh_access_token_logs_invalid_grant_and_direct_connection_refused_separately(self):
failures = [
{
"ok": False,
"endpoint": "entra-common-delegated",
"url": "https://login.microsoftonline.com/common/oauth2/v2.0/token",
"status": 400,
"error": '{"error":"invalid_grant","error_description":"AADSTS70000"}',
"elapsed_ms": 101,
},
{
"ok": False,
"endpoint": "entra-consumers-delegated",
"url": "https://login.microsoftonline.com/consumers/oauth2/v2.0/token",
"status": None,
"error": "Token request failed: <urlopen error [Errno 61] Connection refused>",
"elapsed_ms": 88,
},
]
with mock.patch.object(hotmail_helper, "try_refresh_access_token", side_effect=failures), \
mock.patch.object(hotmail_helper, "get_proxy_debug_context", return_value="direct"):
output = io.StringIO()
with redirect_stdout(output):
with self.assertRaises(RuntimeError):
hotmail_helper.refresh_access_token(
"client-id-demo",
"refresh-token-demo",
["entra-common-delegated", "entra-consumers-delegated"],
)
rendered = output.getvalue()
self.assertIn("category=invalid_grant", rendered)
self.assertIn("category=connection_refused", rendered)
def test_graph_and_outlook_message_urls_are_encoded(self):
captured_urls = []
def fake_get_json(url, headers=None):
captured_urls.append(url)
return 200, {"value": []}
with mock.patch.object(hotmail_helper, "get_json", side_effect=fake_get_json):
hotmail_helper.fetch_graph_messages("access-token-demo", mailbox="INBOX", top=5)
hotmail_helper.fetch_outlook_api_messages("access-token-demo", mailbox="INBOX", top=5)
self.assertEqual(len(captured_urls), 2)
self.assertTrue(all(" " not in url for url in captured_urls))
self.assertIn("%24orderby=receivedDateTime+desc", captured_urls[0])
self.assertIn("%24orderby=ReceivedDateTime+desc", captured_urls[1])
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -319,6 +319,7 @@ return {
test('handlePollEmail skips explicit mismatched target emails when receive-mode matching is enabled', async () => {
const bundle = [
extractFunction('extractEmails'),
extractFunction('extractForwardedTargetEmails'),
extractFunction('emailMatchesTarget'),
extractFunction('getTargetEmailMatchState'),
extractFunction('normalizeMinuteTimestamp'),
+26
View File
@@ -107,6 +107,32 @@ test('PayPal approve keeps original combined email and password login path', asy
assert.equal(events.messages.includes('PAYPAL_CLICK_APPROVE'), true);
});
test('PayPal approve prefers the selected paypal pool account over legacy fields', async () => {
const { executor, events } = createExecutor({
pageStates: [
{ needsLogin: true, hasEmailInput: true, hasPasswordInput: true, loginPhase: 'login_combined' },
{ needsLogin: false, approveReady: true },
{ needsLogin: false, approveReady: true },
],
submitResults: [
{ submitted: true, phase: 'password_submitted', awaiting: 'redirect_or_approval' },
],
});
await executor.executePayPalApprove({
paypalEmail: '',
paypalPassword: '',
currentPayPalAccountId: 'pp-1',
paypalAccounts: [
{ id: 'pp-1', email: 'pool@example.com', password: 'pool-secret' },
],
});
assert.deepStrictEqual(events.submittedPayloads, [
{ email: 'pool@example.com', password: 'pool-secret' },
]);
});
test('PayPal approve discovers an already open unregistered PayPal tab', async () => {
const { executor, events } = createExecutor({
pageStates: [
+105
View File
@@ -145,6 +145,57 @@ return {
`)(document, window);
}
function createSubmitApi(overrides = {}) {
const bindings = {
waitForDocumentComplete: async () => {},
normalizeText: (text = '') => String(text || '').replace(/\s+/g, ' ').trim(),
findPasswordInput: () => null,
findEmailInput: () => null,
findEmailNextButton: () => null,
isEnabledControl: () => true,
findPasswordLoginButton: () => null,
fillInput: () => {},
simulateClick: () => {},
waitUntil: async (predicate) => predicate(),
findLoginNextButton: () => null,
sleep: async () => {},
...overrides,
};
return new Function(
'waitForDocumentComplete',
'normalizeText',
'findPasswordInput',
'findEmailInput',
'findEmailNextButton',
'isEnabledControl',
'findPasswordLoginButton',
'fillInput',
'simulateClick',
'waitUntil',
'findLoginNextButton',
'sleep',
`
${extractFunction('refillPayPalEmailInput')}
${extractFunction('submitPayPalLogin')}
return { refillPayPalEmailInput, submitPayPalLogin };
`
)(
bindings.waitForDocumentComplete,
bindings.normalizeText,
bindings.findPasswordInput,
bindings.findEmailInput,
bindings.findEmailNextButton,
bindings.isEnabledControl,
bindings.findPasswordLoginButton,
bindings.fillInput,
bindings.simulateClick,
bindings.waitUntil,
bindings.findLoginNextButton,
bindings.sleep
);
}
test('PayPal email page ignores hidden pre-rendered password input', () => {
const hiddenPanel = createElement({ attrs: { 'aria-hidden': 'true' } });
const emailInput = createElement({
@@ -203,3 +254,57 @@ test('PayPal combined login page still sees visible password input', () => {
assert.equal(api.findPasswordLoginButton(), loginButton);
assert.equal(api.getPayPalLoginPhase(emailInput, passwordInput), 'login_combined');
});
test('PayPal email submit refills a prefilled email before clicking next', async () => {
const emailInput = createElement({
tag: 'input',
type: 'text',
id: 'login_email',
name: 'login_email',
value: 'user@example.com',
placeholder: 'Email',
});
const nextButton = createElement({
tag: 'button',
id: 'btnNext',
text: 'Next',
});
const fillValues = [];
const clicked = [];
let focusCount = 0;
let blurCount = 0;
emailInput.focus = () => {
focusCount += 1;
};
emailInput.blur = () => {
blurCount += 1;
};
const api = createSubmitApi({
findEmailInput: () => emailInput,
findEmailNextButton: () => nextButton,
fillInput: (element, value) => {
fillValues.push(value);
element.value = value;
},
simulateClick: (element) => {
clicked.push(element);
},
});
const result = await api.submitPayPalLogin({
email: 'user@example.com',
password: 'secret',
});
assert.deepEqual(fillValues, ['', 'user@example.com']);
assert.equal(focusCount, 1);
assert.equal(blurCount, 1);
assert.deepEqual(clicked, [nextButton]);
assert.deepEqual(result, {
submitted: false,
phase: 'email_submitted',
awaiting: 'password_page',
});
});
+346 -242
View File
@@ -32,7 +32,7 @@ function buildHeroSmsStatusV2Payload({ smsCode = '', smsText = '', callCode = ''
});
}
test('phone verification helper requests HeroSMS numbers with fixed OpenAI and Thailand parameters', async () => {
test('phone verification helper requests HeroSMS numbers with manual maxPrice and fixed OpenAI/Thailand parameters', async () => {
const requests = [];
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
@@ -40,26 +40,22 @@ test('phone verification helper requests HeroSMS numbers with fixed OpenAI and T
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
requests.push(parsedUrl);
const action = parsedUrl.searchParams.get('action');
if (action === 'getPrices') {
return {
ok: true,
text: async () => buildHeroSmsPricesPayload(),
};
}
return {
ok: true,
text: async () => 'ACCESS_NUMBER:123456:66959916439',
};
},
getState: async () => ({ heroSmsApiKey: 'demo-key' }),
getState: async () => ({ heroSmsApiKey: 'demo-key', heroSmsMaxPrice: '0.08' }),
sendToContentScriptResilient: async () => ({}),
setState: async () => {},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const activation = await helpers.requestPhoneActivation({ heroSmsApiKey: 'demo-key' });
const activation = await helpers.requestPhoneActivation({
heroSmsApiKey: 'demo-key',
heroSmsMaxPrice: '0.08',
});
assert.deepStrictEqual(activation, {
activationId: '123456',
@@ -70,111 +66,80 @@ test('phone verification helper requests HeroSMS numbers with fixed OpenAI and T
successfulUses: 0,
maxUses: 3,
});
assert.equal(requests.length, 2);
assert.equal(requests[0].searchParams.get('action'), 'getPrices');
assert.equal(requests.length, 1);
assert.equal(requests[0].searchParams.get('action'), 'getNumber');
assert.equal(requests[0].searchParams.get('service'), 'dr');
assert.equal(requests[0].searchParams.get('country'), '52');
assert.equal(requests[0].searchParams.get('api_key'), 'demo-key');
assert.equal(requests[1].searchParams.get('action'), 'getNumber');
assert.equal(requests[1].searchParams.get('maxPrice'), '0.08');
assert.equal(requests[1].searchParams.get('fixedPrice'), 'true');
assert.equal(requests[1].searchParams.get('service'), 'dr');
assert.equal(requests[1].searchParams.get('country'), '52');
assert.equal(requests[1].searchParams.get('api_key'), 'demo-key');
assert.equal(requests[0].searchParams.get('maxPrice'), '0.08');
assert.equal(requests[0].searchParams.get('fixedPrice'), 'true');
});
test('phone verification helper retries HeroSMS getPrices until it receives a usable lowest price', async () => {
const requests = [];
let getPricesAttempt = 0;
test('phone verification helper requires manual HeroSMS maxPrice', async () => {
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
requests.push(parsedUrl);
const action = parsedUrl.searchParams.get('action');
if (action === 'getPrices') {
getPricesAttempt += 1;
return getPricesAttempt < 3
? {
ok: true,
text: async () => JSON.stringify({ unavailable: true }),
}
: {
ok: true,
text: async () => buildHeroSmsPricesPayload({ cost: 0.09 }),
};
}
if (action === 'getNumber') {
return {
ok: true,
text: async () => 'ACCESS_NUMBER:123456:66959916439',
};
}
throw new Error(`Unexpected HeroSMS action: ${action}`);
fetchImpl: async () => {
throw new Error('should not request HeroSMS without maxPrice');
},
getState: async () => ({ heroSmsApiKey: 'demo-key' }),
getState: async () => ({ heroSmsApiKey: 'demo-key', heroSmsMaxPrice: '' }),
sendToContentScriptResilient: async () => ({}),
setState: async () => {},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
await helpers.requestPhoneActivation({ heroSmsApiKey: 'demo-key' });
assert.equal(requests.length, 4);
assert.equal(requests[0].searchParams.get('action'), 'getPrices');
assert.equal(requests[1].searchParams.get('action'), 'getPrices');
assert.equal(requests[2].searchParams.get('action'), 'getPrices');
assert.equal(requests[3].searchParams.get('action'), 'getNumber');
assert.equal(requests[3].searchParams.get('maxPrice'), '0.09');
assert.equal(requests[3].searchParams.get('fixedPrice'), 'true');
await assert.rejects(
() => helpers.requestPhoneActivation({ heroSmsApiKey: 'demo-key', heroSmsMaxPrice: '' }),
/HeroSMS maxPrice is missing/i
);
});
test('phone verification helper falls back to plain getNumber only after HeroSMS getPrices fails three times', async () => {
test('phone verification helper still clears existing activation when maxPrice is missing', async () => {
const requests = [];
let getPricesAttempt = 0;
let currentState = {
heroSmsApiKey: 'demo-key',
heroSmsMaxPrice: '',
currentPhoneActivation: {
activationId: '123456',
phoneNumber: '66959916439',
successfulUses: 0,
maxUses: 3,
},
reusablePhoneActivation: null,
pendingPhoneActivationConfirmation: null,
};
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
requests.push(parsedUrl);
const action = parsedUrl.searchParams.get('action');
if (action === 'getPrices') {
getPricesAttempt += 1;
return {
ok: true,
text: async () => JSON.stringify({ unavailable: getPricesAttempt }),
};
}
if (action === 'getNumber') {
return {
ok: true,
text: async () => 'ACCESS_NUMBER:123456:66959916439',
};
}
throw new Error(`Unexpected HeroSMS action: ${action}`);
return {
ok: true,
text: async () => 'ACCESS_CANCEL',
};
},
getState: async () => ({ heroSmsApiKey: 'demo-key' }),
getState: async () => currentState,
sendToContentScriptResilient: async () => ({}),
setState: async () => {},
setState: async (updates) => {
currentState = { ...currentState, ...updates };
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
await helpers.requestPhoneActivation({ heroSmsApiKey: 'demo-key' });
await assert.rejects(
() => helpers.completePhoneVerificationFlow(1, { addPhonePage: true }),
/HeroSMS maxPrice is missing/i
);
assert.equal(requests.length, 4);
assert.equal(requests[0].searchParams.get('action'), 'getPrices');
assert.equal(requests[1].searchParams.get('action'), 'getPrices');
assert.equal(requests[2].searchParams.get('action'), 'getPrices');
assert.equal(requests[2].searchParams.get('service'), 'dr');
assert.equal(requests[2].searchParams.get('country'), '52');
assert.equal(requests[2].searchParams.get('api_key'), 'demo-key');
assert.equal(requests[3].searchParams.get('action'), 'getNumber');
assert.equal(requests[3].searchParams.get('maxPrice'), null);
assert.equal(requests[3].searchParams.get('fixedPrice'), null);
assert.equal(requests.length, 1);
assert.equal(requests[0].searchParams.get('action'), 'setStatus');
assert.equal(requests[0].searchParams.get('id'), '123456');
assert.equal(requests[0].searchParams.get('status'), '8');
assert.equal(requests[0].searchParams.has('maxPrice'), false);
assert.equal(currentState.currentPhoneActivation, null);
});
test('phone verification helper retries with HeroSMS getNumberV2 when getNumber reports NO_NUMBERS', async () => {
@@ -186,12 +151,6 @@ test('phone verification helper retries with HeroSMS getNumberV2 when getNumber
const parsedUrl = new URL(url);
requests.push(parsedUrl);
const action = parsedUrl.searchParams.get('action');
if (action === 'getPrices') {
return {
ok: true,
text: async () => buildHeroSmsPricesPayload({ country: '16' }),
};
}
if (action === 'getNumber') {
return {
ok: true,
@@ -209,7 +168,7 @@ test('phone verification helper retries with HeroSMS getNumberV2 when getNumber
}
throw new Error(`Unexpected HeroSMS action: ${action}`);
},
getState: async () => ({ heroSmsApiKey: 'demo-key', heroSmsCountryId: 16 }),
getState: async () => ({ heroSmsApiKey: 'demo-key', heroSmsMaxPrice: '0.08', heroSmsCountryId: 16 }),
sendToContentScriptResilient: async () => ({}),
setState: async () => {},
sleepWithStop: async () => {},
@@ -218,6 +177,7 @@ test('phone verification helper retries with HeroSMS getNumberV2 when getNumber
const activation = await helpers.requestPhoneActivation({
heroSmsApiKey: 'demo-key',
heroSmsMaxPrice: '0.08',
heroSmsCountryId: 16,
});
@@ -231,17 +191,15 @@ test('phone verification helper retries with HeroSMS getNumberV2 when getNumber
maxUses: 3,
statusAction: 'getStatusV2',
});
assert.equal(requests.length, 3);
assert.equal(requests[0].searchParams.get('action'), 'getPrices');
assert.equal(requests.length, 2);
assert.equal(requests[0].searchParams.get('action'), 'getNumber');
assert.equal(requests[0].searchParams.get('country'), '16');
assert.equal(requests[1].searchParams.get('action'), 'getNumber');
assert.equal(requests[0].searchParams.get('maxPrice'), '0.08');
assert.equal(requests[0].searchParams.get('fixedPrice'), 'true');
assert.equal(requests[1].searchParams.get('action'), 'getNumberV2');
assert.equal(requests[1].searchParams.get('country'), '16');
assert.equal(requests[1].searchParams.get('maxPrice'), '0.08');
assert.equal(requests[1].searchParams.get('fixedPrice'), 'true');
assert.equal(requests[2].searchParams.get('action'), 'getNumberV2');
assert.equal(requests[2].searchParams.get('country'), '16');
assert.equal(requests[2].searchParams.get('maxPrice'), '0.08');
assert.equal(requests[2].searchParams.get('fixedPrice'), 'true');
});
test('phone verification helper uses HeroSMS getStatusV2 after acquiring a number via getNumberV2', async () => {
@@ -249,6 +207,7 @@ test('phone verification helper uses HeroSMS getStatusV2 after acquiring a numbe
const stateUpdates = [];
let currentState = {
heroSmsApiKey: 'demo-key',
heroSmsMaxPrice: '0.08',
heroSmsCountryId: 16,
heroSmsCountryLabel: 'United Kingdom',
verificationResendCount: 0,
@@ -264,12 +223,6 @@ test('phone verification helper uses HeroSMS getStatusV2 after acquiring a numbe
const parsedUrl = new URL(url);
requests.push(parsedUrl);
const action = parsedUrl.searchParams.get('action');
if (action === 'getPrices') {
return {
ok: true,
text: async () => buildHeroSmsPricesPayload({ country: '16' }),
};
}
if (action === 'getNumber') {
return {
ok: true,
@@ -361,7 +314,19 @@ test('phone verification helper uses HeroSMS getStatusV2 after acquiring a numbe
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 16,
successfulUses: 1,
successfulUses: 0,
maxUses: 3,
statusAction: 'getStatusV2',
},
},
{
pendingPhoneActivationConfirmation: {
activationId: '654321',
phoneNumber: '447911123456',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 16,
successfulUses: 0,
maxUses: 3,
statusAction: 'getStatusV2',
},
@@ -372,7 +337,6 @@ test('phone verification helper uses HeroSMS getStatusV2 after acquiring a numbe
]);
const actions = requests.map((url) => url.searchParams.get('action'));
assert.deepStrictEqual(actions, [
'getPrices',
'getNumber',
'getNumberV2',
'getStatusV2',
@@ -381,117 +345,32 @@ test('phone verification helper uses HeroSMS getStatusV2 after acquiring a numbe
]);
});
test('phone verification helper refreshes maxPrice when HeroSMS returns WRONG_MAX_PRICE', async () => {
const requests = [];
let getNumberAttempt = 0;
test('phone verification helper keeps the user-provided maxPrice and surfaces HeroSMS price errors', async () => {
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
requests.push(parsedUrl);
const action = parsedUrl.searchParams.get('action');
if (action === 'getPrices') {
return {
ok: true,
text: async () => buildHeroSmsPricesPayload(),
};
}
if (action === 'getNumber') {
getNumberAttempt += 1;
return getNumberAttempt === 1
? {
ok: false,
text: async () => 'WRONG_MAX_PRICE:0.09',
}
: {
ok: true,
text: async () => 'ACCESS_NUMBER:123456:66959916439',
};
return {
ok: false,
text: async () => 'WRONG_MAX_PRICE:0.09',
};
}
throw new Error(`Unexpected HeroSMS action: ${action}`);
},
getState: async () => ({ heroSmsApiKey: 'demo-key' }),
getState: async () => ({ heroSmsApiKey: 'demo-key', heroSmsMaxPrice: '0.08' }),
sendToContentScriptResilient: async () => ({}),
setState: async () => {},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const activation = await helpers.requestPhoneActivation({ heroSmsApiKey: 'demo-key' });
assert.deepStrictEqual(activation, {
activationId: '123456',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 0,
maxUses: 3,
});
assert.equal(requests.length, 3);
assert.equal(requests[0].searchParams.get('action'), 'getPrices');
assert.equal(requests[1].searchParams.get('action'), 'getNumber');
assert.equal(requests[1].searchParams.get('maxPrice'), '0.08');
assert.equal(requests[2].searchParams.get('action'), 'getNumber');
assert.equal(requests[2].searchParams.get('maxPrice'), '0.09');
assert.equal(requests[2].searchParams.get('fixedPrice'), 'true');
});
test('phone verification helper falls back to plain getNumber when priced request fails to fetch', async () => {
const requests = [];
let getNumberAttempt = 0;
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
requests.push(parsedUrl);
const action = parsedUrl.searchParams.get('action');
if (action === 'getPrices') {
return {
ok: true,
text: async () => buildHeroSmsPricesPayload(),
};
}
if (action === 'getNumber') {
getNumberAttempt += 1;
if (getNumberAttempt === 1) {
throw new TypeError('Failed to fetch');
}
return {
ok: true,
text: async () => 'ACCESS_NUMBER:123456:66959916439',
};
}
throw new Error(`Unexpected HeroSMS action: ${action}`);
},
getState: async () => ({ heroSmsApiKey: 'demo-key' }),
sendToContentScriptResilient: async () => ({}),
setState: async () => {},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const activation = await helpers.requestPhoneActivation({ heroSmsApiKey: 'demo-key' });
assert.deepStrictEqual(activation, {
activationId: '123456',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 0,
maxUses: 3,
});
assert.equal(requests.length, 3);
assert.equal(requests[0].searchParams.get('action'), 'getPrices');
assert.equal(requests[1].searchParams.get('action'), 'getNumber');
assert.equal(requests[1].searchParams.get('maxPrice'), '0.08');
assert.equal(requests[1].searchParams.get('fixedPrice'), 'true');
assert.equal(requests[2].searchParams.get('action'), 'getNumber');
assert.equal(requests[2].searchParams.get('maxPrice'), null);
assert.equal(requests[2].searchParams.get('fixedPrice'), null);
await assert.rejects(
() => helpers.requestPhoneActivation({ heroSmsApiKey: 'demo-key', heroSmsMaxPrice: '0.08' }),
/WRONG_MAX_PRICE:0.09/
);
});
test('phone verification helper completes add-phone flow, clears current activation, and stores reusable number state', async () => {
@@ -499,6 +378,7 @@ test('phone verification helper completes add-phone flow, clears current activat
const stateUpdates = [];
let currentState = {
heroSmsApiKey: 'demo-key',
heroSmsMaxPrice: '0.08',
verificationResendCount: 1,
currentPhoneActivation: null,
reusablePhoneActivation: null,
@@ -511,12 +391,6 @@ test('phone verification helper completes add-phone flow, clears current activat
const parsedUrl = new URL(url);
requests.push(parsedUrl);
const action = parsedUrl.searchParams.get('action');
if (action === 'getPrices') {
return {
ok: true,
text: async () => buildHeroSmsPricesPayload(),
};
}
if (action === 'getNumber') {
return {
ok: true,
@@ -593,7 +467,18 @@ test('phone verification helper completes add-phone flow, clears current activat
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 1,
successfulUses: 0,
maxUses: 3,
},
},
{
pendingPhoneActivationConfirmation: {
activationId: '123456',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 0,
maxUses: 3,
},
},
@@ -603,7 +488,87 @@ test('phone verification helper completes add-phone flow, clears current activat
]);
const actions = requests.map((url) => url.searchParams.get('action'));
assert.deepStrictEqual(actions, ['getPrices', 'getNumber', 'getStatus', 'setStatus']);
assert.deepStrictEqual(actions, ['getNumber', 'getStatus', 'setStatus']);
});
test('phone verification helper still succeeds when HeroSMS setStatus(3) fails after a successful submit', async () => {
const requests = [];
let currentState = {
heroSmsApiKey: 'demo-key',
heroSmsMaxPrice: '0.08',
verificationResendCount: 1,
currentPhoneActivation: null,
reusablePhoneActivation: null,
};
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
requests.push(parsedUrl);
const action = parsedUrl.searchParams.get('action');
if (action === 'getNumber') {
return {
ok: true,
text: async () => 'ACCESS_NUMBER:123456:66959916439',
};
}
if (action === 'getStatus') {
return {
ok: true,
text: async () => 'STATUS_OK:654321',
};
}
if (action === 'setStatus') {
return {
ok: false,
text: async () => 'TEMPORARY_ERROR',
};
}
throw new Error(`Unexpected HeroSMS action: ${action}`);
},
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs,
getState: async () => ({ ...currentState }),
sendToContentScriptResilient: async (_source, message) => {
if (message.type === 'SUBMIT_PHONE_NUMBER') {
return {
phoneVerificationPage: true,
url: 'https://auth.openai.com/phone-verification',
};
}
if (message.type === 'SUBMIT_PHONE_VERIFICATION_CODE') {
return {
success: true,
consentReady: true,
url: 'https://auth.openai.com/authorize',
};
}
throw new Error(`Unexpected content-script message: ${message.type}`);
},
setState: async (updates) => {
currentState = { ...currentState, ...updates };
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const result = await helpers.completePhoneVerificationFlow(1, {
addPhonePage: true,
phoneVerificationPage: false,
url: 'https://auth.openai.com/add-phone',
});
assert.deepStrictEqual(result, {
success: true,
consentReady: true,
url: 'https://auth.openai.com/authorize',
});
assert.deepStrictEqual(currentState.currentPhoneActivation, null);
assert.deepStrictEqual(currentState.reusablePhoneActivation, null);
assert.deepStrictEqual(currentState.pendingPhoneActivationConfirmation, null);
const actions = requests.map((url) => url.searchParams.get('action'));
assert.deepStrictEqual(actions, ['getNumber', 'getStatus', 'setStatus']);
});
test('phone verification helper uses the configured HeroSMS country for both number acquisition and add-phone submission', async () => {
@@ -611,6 +576,7 @@ test('phone verification helper uses the configured HeroSMS country for both num
const submittedPayloads = [];
let currentState = {
heroSmsApiKey: 'demo-key',
heroSmsMaxPrice: '0.08',
heroSmsCountryId: 16,
heroSmsCountryLabel: 'United Kingdom',
verificationResendCount: 0,
@@ -625,12 +591,6 @@ test('phone verification helper uses the configured HeroSMS country for both num
const parsedUrl = new URL(url);
requests.push(parsedUrl);
const action = parsedUrl.searchParams.get('action');
if (action === 'getPrices') {
return {
ok: true,
text: async () => buildHeroSmsPricesPayload({ country: '16' }),
};
}
if (action === 'getNumber') {
return {
ok: true,
@@ -688,12 +648,10 @@ test('phone verification helper uses the configured HeroSMS country for both num
consentReady: true,
url: 'https://auth.openai.com/authorize',
});
assert.equal(requests[0].searchParams.get('action'), 'getPrices');
assert.equal(requests[0].searchParams.get('action'), 'getNumber');
assert.equal(requests[0].searchParams.get('country'), '16');
assert.equal(requests[1].searchParams.get('action'), 'getNumber');
assert.equal(requests[1].searchParams.get('country'), '16');
assert.equal(requests[1].searchParams.get('maxPrice'), '0.08');
assert.equal(requests[1].searchParams.get('fixedPrice'), 'true');
assert.equal(requests[0].searchParams.get('maxPrice'), '0.08');
assert.equal(requests[0].searchParams.get('fixedPrice'), 'true');
assert.deepStrictEqual(submittedPayloads, [{
phoneNumber: '447911123456',
countryId: 16,
@@ -704,8 +662,10 @@ test('phone verification helper uses the configured HeroSMS country for both num
test('phone verification helper throws a step-7 restart error after 60 seconds plus one resend window without SMS', async () => {
const requests = [];
const messages = [];
const stateUpdates = [];
let currentState = {
heroSmsApiKey: 'demo-key',
heroSmsMaxPrice: '0.08',
verificationResendCount: 0,
currentPhoneActivation: null,
reusablePhoneActivation: null,
@@ -725,13 +685,6 @@ test('phone verification helper throws a step-7 restart error after 60 seconds p
const action = parsedUrl.searchParams.get('action');
const id = parsedUrl.searchParams.get('id');
if (action === 'getPrices') {
return {
ok: true,
text: async () => buildHeroSmsPricesPayload(),
};
}
if (action === 'getNumber') {
return {
ok: true,
@@ -775,6 +728,7 @@ test('phone verification helper throws a step-7 restart error after 60 seconds p
throw new Error(`Unexpected content-script message: ${message.type}`);
},
setState: async (updates) => {
stateUpdates.push(updates);
currentState = { ...currentState, ...updates };
},
sleepWithStop: async () => {
@@ -799,7 +753,6 @@ test('phone verification helper throws a step-7 restart error after 60 seconds p
const actions = requests.map((url) => `${url.searchParams.get('action')}:${url.searchParams.get('id') || ''}`);
assert.deepStrictEqual(actions, [
'getPrices:',
'getNumber:',
'getStatus:123456',
'setStatus:123456',
@@ -807,6 +760,11 @@ test('phone verification helper throws a step-7 restart error after 60 seconds p
'setStatus:123456',
]);
assert.equal(currentState.currentPhoneActivation, null);
assert.equal(
stateUpdates.some((updates) => Number(updates.currentPhoneActivation?.successfulUses) > 0),
false,
'60 seconds without SMS should not increment reuse count'
);
} finally {
Date.now = realDateNow;
}
@@ -817,6 +775,7 @@ test('phone verification helper replaces the number when code submission returns
const messages = [];
let currentState = {
heroSmsApiKey: 'demo-key',
heroSmsMaxPrice: '0.08',
verificationResendCount: 1,
currentPhoneActivation: null,
reusablePhoneActivation: null,
@@ -838,13 +797,6 @@ test('phone verification helper replaces the number when code submission returns
const action = parsedUrl.searchParams.get('action');
const id = parsedUrl.searchParams.get('id');
if (action === 'getPrices') {
return {
ok: true,
text: async () => buildHeroSmsPricesPayload(),
};
}
if (action === 'getNumber') {
const nextNumber = numbers[numberIndex];
numberIndex += 1;
@@ -925,11 +877,9 @@ test('phone verification helper replaces the number when code submission returns
const actions = requests.map((url) => `${url.searchParams.get('action')}:${url.searchParams.get('id') || ''}`);
assert.deepStrictEqual(actions, [
'getPrices:',
'getNumber:',
'getStatus:111111',
'setStatus:111111',
'getPrices:',
'getNumber:',
'getStatus:222222',
'setStatus:222222',
@@ -941,15 +891,25 @@ test('phone verification helper replaces the number when code submission returns
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 1,
successfulUses: 0,
maxUses: 3,
});
assert.deepStrictEqual(currentState.pendingPhoneActivationConfirmation, {
activationId: '222222',
phoneNumber: '66950000002',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 0,
maxUses: 3,
});
});
test('phone verification helper reuses the same number up to three successful registrations', async () => {
test('phone verification helper defers maxUses accounting for reused activations until the full flow succeeds', async () => {
const requests = [];
let currentState = {
heroSmsApiKey: 'demo-key',
heroSmsMaxPrice: '0.08',
verificationResendCount: 0,
currentPhoneActivation: null,
reusablePhoneActivation: {
@@ -1031,13 +991,31 @@ test('phone verification helper reuses the same number up to three successful re
});
assert.equal(requests[0].searchParams.get('action'), 'reactivate');
assert.equal(requests[0].searchParams.get('id'), '123456');
assert.deepStrictEqual(currentState.reusablePhoneActivation, null);
assert.deepStrictEqual(currentState.reusablePhoneActivation, {
activationId: '222333',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 2,
maxUses: 3,
});
assert.deepStrictEqual(currentState.pendingPhoneActivationConfirmation, {
activationId: '222333',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 2,
maxUses: 3,
});
});
test('phone verification helper keeps maxUses behavior for reused V2 activations', async () => {
test('phone verification helper defers maxUses accounting for reused V2 activations until the full flow succeeds', async () => {
const requests = [];
let currentState = {
heroSmsApiKey: 'demo-key',
heroSmsMaxPrice: '0.08',
heroSmsCountryId: 16,
heroSmsCountryLabel: 'United Kingdom',
verificationResendCount: 0,
@@ -1122,5 +1100,131 @@ test('phone verification helper keeps maxUses behavior for reused V2 activations
});
const actions = requests.map((url) => url.searchParams.get('action'));
assert.deepStrictEqual(actions, ['reactivate', 'getStatusV2', 'setStatus']);
assert.deepStrictEqual(currentState.reusablePhoneActivation, null);
assert.deepStrictEqual(currentState.reusablePhoneActivation, {
activationId: '222333',
phoneNumber: '447911123456',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 16,
successfulUses: 2,
maxUses: 3,
statusAction: 'getStatusV2',
});
assert.deepStrictEqual(currentState.pendingPhoneActivationConfirmation, {
activationId: '222333',
phoneNumber: '447911123456',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 16,
successfulUses: 2,
maxUses: 3,
statusAction: 'getStatusV2',
});
});
test('phone verification helper finalizes pending phone activation confirmation after the full flow succeeds', async () => {
let currentState = {
reusablePhoneActivation: {
activationId: '123456',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 0,
maxUses: 3,
},
pendingPhoneActivationConfirmation: {
activationId: '123456',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 0,
maxUses: 3,
},
};
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
getState: async () => ({ ...currentState }),
sendToContentScriptResilient: async () => ({}),
setState: async (updates) => {
currentState = { ...currentState, ...updates };
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const committedActivation = await helpers.finalizePendingPhoneActivationConfirmation();
assert.deepStrictEqual(committedActivation, {
activationId: '123456',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 1,
maxUses: 3,
});
assert.deepStrictEqual(currentState.reusablePhoneActivation, {
activationId: '123456',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 1,
maxUses: 3,
});
assert.equal(currentState.pendingPhoneActivationConfirmation, null);
});
test('phone verification helper clears reusable activation when final success exhausts maxUses', async () => {
let currentState = {
reusablePhoneActivation: {
activationId: '222333',
phoneNumber: '447911123456',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 16,
successfulUses: 2,
maxUses: 3,
statusAction: 'getStatusV2',
},
pendingPhoneActivationConfirmation: {
activationId: '222333',
phoneNumber: '447911123456',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 16,
successfulUses: 2,
maxUses: 3,
statusAction: 'getStatusV2',
},
};
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
getState: async () => ({ ...currentState }),
sendToContentScriptResilient: async () => ({}),
setState: async (updates) => {
currentState = { ...currentState, ...updates };
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const committedActivation = await helpers.finalizePendingPhoneActivationConfirmation();
assert.deepStrictEqual(committedActivation, {
activationId: '222333',
phoneNumber: '447911123456',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 16,
successfulUses: 3,
maxUses: 3,
statusAction: 'getStatusV2',
});
assert.equal(currentState.reusablePhoneActivation, null);
assert.equal(currentState.pendingPhoneActivationConfirmation, null);
});
@@ -61,6 +61,9 @@ function createRow(initialDisplay = 'none') {
test('sidepanel html places cloudflare temp email controls in a standalone section', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
assert.match(html, /id="cloudflare-temp-email-section"/);
assert.match(html, /id="btn-toggle-cloudflare-temp-email-section"/);
assert.match(html, /aria-controls="cloudflare-temp-email-section-body"/);
assert.match(html, /id="cloudflare-temp-email-section-body" class="section-collapse-body" hidden/);
assert.match(html, /id="btn-cloudflare-temp-email-usage-guide"/);
assert.match(html, /id="btn-cloudflare-temp-email-github"/);
assert.match(html, /id="row-temp-email-random-subdomain-toggle"/);
@@ -68,6 +71,16 @@ test('sidepanel html places cloudflare temp email controls in a standalone secti
assert.doesNotMatch(html, /id="row-temp-email-random-subdomain-domain"/);
});
test('sidepanel persists cloudflare temp email section collapse state', () => {
assert.match(source, /CLOUDFLARE_TEMP_EMAIL_SECTION_EXPANDED_STORAGE_KEY = 'multipage-cloudflare-temp-email-section-expanded'/);
assert.match(source, /let cloudflareTempEmailSectionExpanded = false/);
assert.match(source, /function updateCloudflareTempEmailSectionExpandedUI\(\)/);
assert.match(source, /cloudflareTempEmailSectionBody\.hidden = !expanded/);
assert.match(source, /btnToggleCloudflareTempEmailSection\.setAttribute\('aria-expanded', String\(expanded\)\)/);
assert.match(source, /btnToggleCloudflareTempEmailSection\?\.addEventListener\('click', \(\) => \{\s*toggleCloudflareTempEmailSectionExpanded\(\)/);
assert.match(source, /initCloudflareTempEmailSectionExpandedState\(\)/);
});
test('sidepanel modal message preserves line breaks and supports inline links', () => {
const css = fs.readFileSync('sidepanel/sidepanel.css', 'utf8');
assert.match(css, /\.modal-message\s*\{[\s\S]*white-space:\s*pre-line;/);
+16
View File
@@ -2,6 +2,8 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const sidepanelSource = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
function createAccountPoolUiStub() {
return {
createAccountPoolFormController({
@@ -60,11 +62,25 @@ test('sidepanel loads hotmail manager before sidepanel bootstrap', () => {
test('sidepanel html contains collapsible hotmail form controls', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
assert.match(html, /id="btn-toggle-hotmail-section"/);
assert.match(html, /aria-controls="hotmail-section-body"/);
assert.match(html, /id="hotmail-section-body" class="section-collapse-body" hidden/);
assert.match(html, /id="btn-toggle-hotmail-form"/);
assert.match(html, /id="hotmail-form-shell"/);
assert.match(html, /id="btn-import-hotmail-accounts"[^>]*>批量导入</);
});
test('sidepanel keeps hotmail account pool behind a persisted section collapse', () => {
assert.match(sidepanelSource, /HOTMAIL_SECTION_EXPANDED_STORAGE_KEY = 'multipage-hotmail-section-expanded'/);
assert.match(sidepanelSource, /let hotmailSectionExpanded = false/);
assert.match(sidepanelSource, /function updateHotmailSectionExpandedUI\(\)/);
assert.match(sidepanelSource, /hotmailSectionBody\.hidden = !expanded/);
assert.match(sidepanelSource, /btnToggleHotmailSection\.setAttribute\('aria-expanded', String\(expanded\)\)/);
assert.match(sidepanelSource, /btnToggleHotmailSection\?\.addEventListener\('click', \(\) => \{\s*toggleHotmailSectionExpanded\(\)/);
assert.match(sidepanelSource, /btnToggleHotmailForm\?\.addEventListener\('click', \(\) => \{\s*setHotmailSectionExpanded\(true\)/);
assert.match(sidepanelSource, /initHotmailSectionExpandedState\(\)/);
});
test('hotmail manager exposes a factory and renders empty state', () => {
const source = fs.readFileSync('sidepanel/hotmail-manager.js', 'utf8');
const windowObject = {
+2
View File
@@ -321,6 +321,7 @@ const inputVerificationResendCount = { value: '' };
const inputPhoneVerificationEnabled = { checked: false };
const DEFAULT_PHONE_VERIFICATION_ENABLED = false;
const inputHeroSmsApiKey = { value: '' };
const inputHeroSmsMaxPrice = { value: '' };
const selectHeroSmsCountry = { value: '52', options: [{ value: '52' }] };
const inputRunCount = { value: '' };
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
@@ -347,6 +348,7 @@ function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) ||
function normalizeAutoDelayMinutes(value) { return Number(value) || 30; }
function formatAutoStepDelayInputValue(value) { return value == null ? '' : String(value); }
function normalizeVerificationResendCount(value, fallback) { return Number(value) || fallback; }
function normalizeHeroSmsMaxPriceValue(value) { return String(value ?? '').trim(); }
function normalizeHeroSmsCountryId() { return 52; }
function getSelectedHeroSmsCountryOption() { return { label: 'Thailand' }; }
function updateHeroSmsPlatformDisplay() {}
@@ -0,0 +1,39 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
test('sidepanel password inputs expose visibility toggles', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
const passwordInputIds = Array.from(
html.matchAll(/<input\b[^>]*type="password"[^>]*id="([^"]+)"/g),
(match) => match[1]
);
const legacyToggleIds = new Map([
['input-vps-url', 'btn-toggle-vps-url'],
['input-vps-password', 'btn-toggle-vps-password'],
['input-ip-proxy-username', 'btn-toggle-ip-proxy-username'],
['input-ip-proxy-password', 'btn-toggle-ip-proxy-password'],
['input-ip-proxy-api-url', 'btn-toggle-ip-proxy-api-url'],
['input-password', 'btn-toggle-password'],
]);
assert.ok(passwordInputIds.length > 0);
for (const inputId of passwordInputIds) {
const hasDataToggle = html.includes(`data-password-toggle="${inputId}"`);
const legacyToggleId = legacyToggleIds.get(inputId);
const hasLegacyToggle = legacyToggleId ? html.includes(`id="${legacyToggleId}"`) : false;
assert.equal(
hasDataToggle || hasLegacyToggle,
true,
`${inputId} should have a visibility toggle button`
);
}
});
test('shared form dialog adds visibility toggles for password fields', () => {
const source = fs.readFileSync('sidepanel/form-dialog.js', 'utf8');
assert.match(source, /field\.type === 'password'[\s\S]*data-input-with-icon/);
assert.match(source, /syncPasswordToggleButton\(toggleButton,\s*input,\s*labels\)/);
assert.match(source, /input\.type = input\.type === 'password' \? 'text' : 'password'/);
});
+133
View File
@@ -0,0 +1,133 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
test('sidepanel loads reusable form dialog and paypal manager before sidepanel bootstrap', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
const formDialogIndex = html.indexOf('<script src="form-dialog.js"></script>');
const managerIndex = html.indexOf('<script src="paypal-manager.js"></script>');
const sidepanelIndex = html.indexOf('<script src="sidepanel.js"></script>');
assert.notEqual(formDialogIndex, -1);
assert.notEqual(managerIndex, -1);
assert.notEqual(sidepanelIndex, -1);
assert.ok(formDialogIndex < managerIndex);
assert.ok(managerIndex < sidepanelIndex);
});
test('sidepanel html contains paypal select and add button controls', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
assert.match(html, /id="row-paypal-account"/);
assert.match(html, /id="select-paypal-account"/);
assert.match(html, /id="btn-add-paypal-account"/);
assert.match(html, /id="shared-form-modal"/);
});
test('paypal manager saves a paypal account and selects it immediately', async () => {
const source = fs.readFileSync('sidepanel/paypal-manager.js', 'utf8');
const windowObject = {};
const api = new Function('window', `${source}; return window.SidepanelPayPalManager;`)(windowObject);
let latestState = {
paypalAccounts: [],
currentPayPalAccountId: null,
paypalEmail: '',
paypalPassword: '',
};
const events = [];
const clickHandlers = {};
const changeHandlers = {};
const selectNode = {
innerHTML: '',
value: '',
disabled: false,
addEventListener(type, handler) {
changeHandlers[type] = handler;
},
};
const addButton = {
disabled: false,
addEventListener(type, handler) {
clickHandlers[type] = handler;
},
};
const manager = api.createPayPalManager({
state: {
getLatestState: () => latestState,
syncLatestState(updates) {
latestState = { ...latestState, ...updates };
},
},
dom: {
btnAddPayPalAccount: addButton,
selectPayPalAccount: selectNode,
},
helpers: {
escapeHtml: (value) => String(value || ''),
getPayPalAccounts: (state) => Array.isArray(state?.paypalAccounts) ? state.paypalAccounts : [],
openFormDialog: async () => ({ email: 'user@example.com', password: 'secret' }),
showToast(message, tone) {
events.push({ type: 'toast', message, tone });
},
},
runtime: {
sendMessage: async (message) => {
events.push({ type: 'message', message });
if (message.type === 'UPSERT_PAYPAL_ACCOUNT') {
return {
ok: true,
account: {
id: 'pp-1',
email: 'user@example.com',
password: 'secret',
},
};
}
if (message.type === 'SELECT_PAYPAL_ACCOUNT') {
return {
ok: true,
account: {
id: 'pp-1',
email: 'user@example.com',
password: 'secret',
},
};
}
throw new Error(`unexpected message ${message.type}`);
},
},
paypalUtils: {
upsertPayPalAccountInList(accounts, nextAccount) {
const list = Array.isArray(accounts) ? accounts.slice() : [];
const existingIndex = list.findIndex((account) => account.id === nextAccount.id);
if (existingIndex >= 0) {
list[existingIndex] = nextAccount;
return list;
}
list.push(nextAccount);
return list;
},
},
});
manager.bindPayPalEvents();
manager.renderPayPalAccounts();
assert.match(selectNode.innerHTML, /请先添加 PayPal 账号/);
clickHandlers.click();
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
assert.deepStrictEqual(
events.filter((event) => event.type === 'message').map((event) => event.message.type),
['UPSERT_PAYPAL_ACCOUNT', 'SELECT_PAYPAL_ACCOUNT']
);
assert.equal(latestState.currentPayPalAccountId, 'pp-1');
assert.equal(latestState.paypalEmail, 'user@example.com');
assert.equal(latestState.paypalPassword, 'secret');
assert.equal(selectNode.value, 'pp-1');
assert.equal(selectNode.disabled, false);
assert.match(events.at(-1)?.message || '', /已保存 PayPal 账号/);
});
@@ -7,6 +7,7 @@ const {
} = require('../mail-provider-utils');
const sidepanelSource = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
const ipProxyPanelSource = fs.readFileSync('sidepanel/ip-proxy-panel.js', 'utf8');
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
@@ -57,17 +58,51 @@ test('sidepanel html exposes phone verification toggle and dedicated HeroSMS row
assert.match(html, /id="row-phone-verification-enabled"/);
assert.match(html, /id="input-phone-verification-enabled"/);
assert.match(html, /id="ip-proxy-section"/);
assert.match(html, /id="row-ip-proxy-enabled"/);
assert.match(html, /id="input-ip-proxy-enabled"/);
assert.match(html, /id="row-hero-sms-platform"/);
assert.match(html, /id="row-hero-sms-country"/);
assert.match(html, /id="row-hero-sms-max-price"/);
assert.match(html, /id="row-hero-sms-api-key"/);
assert.doesNotMatch(html, /id="input-account-run-history-text-enabled"/);
});
test('sidepanel renders IP proxy as a standalone card after sms verification without proxy status chrome', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
const phoneToggleIndex = html.indexOf('id="row-phone-verification-enabled"');
const ipProxySectionIndex = html.indexOf('id="ip-proxy-section"');
const ipProxyToggleIndex = html.indexOf('id="row-ip-proxy-enabled"');
const cloudflareSectionIndex = html.indexOf('id="cloudflare-temp-email-section"');
assert.match(html, /id="ip-proxy-section" class="data-card ip-proxy-card"/);
assert.match(html, /id="btn-toggle-ip-proxy-section"/);
assert.match(html, /aria-controls="row-ip-proxy-fold"/);
assert.match(html, />展开设置<\/button>/);
assert.ok(phoneToggleIndex >= 0);
assert.ok(ipProxySectionIndex > phoneToggleIndex);
assert.ok(ipProxyToggleIndex > phoneToggleIndex);
assert.ok(cloudflareSectionIndex > ipProxySectionIndex);
assert.doesNotMatch(html, /id="ip-proxy-enabled-status"/);
assert.doesNotMatch(html, /id="row-ip-proxy-runtime-status"/);
});
test('IP proxy standalone card supports persisted collapse control', () => {
assert.match(ipProxyPanelSource, /IP_PROXY_SECTION_EXPANDED_STORAGE_KEY = 'multipage-ip-proxy-section-expanded'/);
assert.match(ipProxyPanelSource, /let ipProxySectionExpanded = false/);
assert.match(ipProxyPanelSource, /const showSettings = enabled && ipProxySectionExpanded/);
assert.match(ipProxyPanelSource, /rowIpProxyFold\.style\.display = showSettings \? '' : 'none'/);
assert.match(ipProxyPanelSource, /btnToggleIpProxySection\.setAttribute\('aria-expanded', String\(showSettings\)\)/);
assert.match(sidepanelSource, /btnToggleIpProxySection\?\.addEventListener\('click', \(\) => \{\s*if \(typeof toggleIpProxySectionExpanded === 'function'\)/);
assert.match(sidepanelSource, /initIpProxySectionExpandedState\(\)/);
});
test('updatePhoneVerificationSettingsUI toggles HeroSMS rows from the sms switch', () => {
const api = new Function(`
const inputPhoneVerificationEnabled = { checked: false };
const rowHeroSmsPlatform = { style: { display: 'none' } };
const rowHeroSmsCountry = { style: { display: 'none' } };
const rowHeroSmsMaxPrice = { style: { display: 'none' } };
const rowHeroSmsApiKey = { style: { display: 'none' } };
${extractFunction('updatePhoneVerificationSettingsUI')}
@@ -76,6 +111,7 @@ return {
inputPhoneVerificationEnabled,
rowHeroSmsPlatform,
rowHeroSmsCountry,
rowHeroSmsMaxPrice,
rowHeroSmsApiKey,
updatePhoneVerificationSettingsUI,
};
@@ -84,12 +120,14 @@ return {
api.updatePhoneVerificationSettingsUI();
assert.equal(api.rowHeroSmsPlatform.style.display, 'none');
assert.equal(api.rowHeroSmsCountry.style.display, 'none');
assert.equal(api.rowHeroSmsMaxPrice.style.display, 'none');
assert.equal(api.rowHeroSmsApiKey.style.display, 'none');
api.inputPhoneVerificationEnabled.checked = true;
api.updatePhoneVerificationSettingsUI();
assert.equal(api.rowHeroSmsPlatform.style.display, '');
assert.equal(api.rowHeroSmsCountry.style.display, '');
assert.equal(api.rowHeroSmsMaxPrice.style.display, '');
assert.equal(api.rowHeroSmsApiKey.style.display, '');
});
@@ -141,6 +179,7 @@ const inputAutoStepDelaySeconds = { value: '' };
const inputPhoneVerificationEnabled = { checked: true };
const inputVerificationResendCount = { value: '4' };
const inputHeroSmsApiKey = { value: 'demo-key' };
const inputHeroSmsMaxPrice = { value: '0.08' };
const inputAccountRunHistoryHelperBaseUrl = { value: 'http://127.0.0.1:17373' };
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
const DEFAULT_HERO_SMS_COUNTRY_ID = 52;
@@ -169,6 +208,7 @@ function normalizeAutoStepDelaySeconds(value) { return value === '' ? null : Num
function normalizeVerificationResendCount(value, fallback) { return Number(value) || fallback; }
${extractFunction('normalizeHeroSmsCountryId')}
${extractFunction('normalizeHeroSmsCountryLabel')}
${extractFunction('normalizeHeroSmsMaxPriceValue')}
${extractFunction('getSelectedHeroSmsCountryOption')}
${extractFunction('collectSettingsPayload')}
return { collectSettingsPayload };
@@ -180,6 +220,7 @@ return { collectSettingsPayload };
assert.equal(payload.accountRunHistoryTextEnabled, true);
assert.equal(payload.accountRunHistoryHelperBaseUrl, 'http://127.0.0.1:17373');
assert.equal(payload.heroSmsApiKey, 'demo-key');
assert.equal(payload.heroSmsMaxPrice, '0.08');
assert.equal(payload.heroSmsCountryId, 52);
assert.equal(payload.heroSmsCountryLabel, 'Thailand');
});
+3 -2
View File
@@ -68,6 +68,7 @@ test('sidepanel html loads shared step definitions before sidepanel bootstrap',
test('sidepanel html exposes Plus mode and PayPal settings', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
assert.match(html, /id="input-plus-mode-enabled"/);
assert.match(html, /id="input-paypal-email"/);
assert.match(html, /id="input-paypal-password"/);
assert.match(html, /id="select-paypal-account"/);
assert.match(html, /id="btn-add-paypal-account"/);
assert.match(html, /id="shared-form-modal"/);
});
@@ -114,6 +114,7 @@ function broadcastDataUpdate() {}
async function addLog(message) {
logMessages.push(message);
}
async function finalizePhoneActivationAfterSuccessfulFlow() {}
async function finalizeIcloudAliasAfterSuccessfulFlow() {}
function matchesSourceUrlFamily() {
return false;
+71
View File
@@ -1030,6 +1030,77 @@ test('verification flow waits during resend cooldown instead of tight-looping',
assert.ok(sleepCalls[0] >= 1000);
});
test('verification flow notifies onResendRequestedAt when resend is triggered', async () => {
const resendRequestedAtCalls = [];
const stateUpdates = [];
let pollCalls = 0;
const helpers = api.createVerificationFlowHelpers({
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async () => {},
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
getHotmailVerificationPollConfig: () => ({}),
getHotmailVerificationRequestTimestamp: () => 0,
getState: async () => ({}),
getTabId: async () => 1,
HOTMAIL_PROVIDER: 'hotmail-api',
isStopError: () => false,
LUCKMAIL_PROVIDER: 'luckmail-api',
MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
pollCloudflareTempEmailVerificationCode: async () => ({}),
pollHotmailVerificationCode: async () => ({}),
pollLuckmailVerificationCode: async () => ({}),
sendToContentScript: async (_source, message) => {
if (message.type === 'RESEND_VERIFICATION_CODE') {
return { resent: true };
}
return {};
},
sendToMailContentScriptResilient: async (_mail, message) => {
if (message.type !== 'POLL_EMAIL') {
return {};
}
pollCalls += 1;
return pollCalls === 1
? {}
: { code: '654321', emailTimestamp: 123 };
},
setState: async (payload) => {
stateUpdates.push(payload);
},
setStepStatus: async () => {},
sleepWithStop: async () => {},
throwIfStopped: () => {},
VERIFICATION_POLL_MAX_ROUNDS: 5,
});
await helpers.resolveVerificationStep(
8,
{
email: 'user@example.com',
lastLoginCode: null,
},
{ provider: 'qq', label: 'QQ 邮箱' },
{
maxResendRequests: 1,
resendIntervalMs: 25000,
onResendRequestedAt: async (requestedAt) => {
resendRequestedAtCalls.push(Number(requestedAt) || 0);
},
}
);
assert.equal(resendRequestedAtCalls.length >= 1, true);
assert.equal(resendRequestedAtCalls[0] > 0, true);
assert.equal(
stateUpdates.some((payload) => Number(payload?.loginVerificationRequestedAt) > 0),
true
);
});
test('verification flow uses resilient signup-page transport when submitting verification code', async () => {
const resilientCalls = [];
+32 -2
View File
@@ -42,8 +42,9 @@
- 在顶部“贡献/使用”按钮下方展示一个非强制的内容更新轻提示;提示来源于 `apikey.qzz.io` 的公开公告 / 教程摘要,用户关闭后仅对当前 `promptVersion` 静默,下次内容版本变化后会重新出现
- 在 sidepanel 初始化和点击“自动”按钮前刷新一次贡献站公开内容摘要;如果刷新失败,不阻塞主自动流程
- 在日志区通过“记录”按钮打开独立的邮箱记录覆盖层,并展示成功/失败/停止/重试统计与分页列表
- 查询 GitHub Releases 并展示更新卡片;当前更新服务会区分 `Pro` 与 legacy `v` 个版本族,排序时优先保持版本族语义一致,同时会在读取缓存后重新排序,避免旧缓存把 `v` 版本误显示为比 `Pro` 更新
- 查询 GitHub Releases 并展示更新卡片;当前更新服务会区分 `Ultra`、历史 `Pro` 与 legacy `v` 个版本族,排序时固定以 `Ultra` 为最高正式系列,同时会在读取缓存后重新排序,避免历史 `Pro` `v` 版本误显示为比 `Ultra` 更新
- 展示一个单独的“接码”开关;开启后才展示 HeroSMS 的接码国家与 API Key 设置,用于 OAuth 登录链路命中手机号验证页时直接续跑手机验证
- 展示 `Plus 模式` 开关与 PayPal 账号池配置;开启后 PayPal 配置行会切换为“账号下拉框 + 添加按钮”,添加按钮复用公共表单弹窗录入账号和密码;步骤列表切换为 Plus 模式 13 步定义,普通模式的 Cookie 清理步骤不再显示或执行,登录验证码步骤会移动到 Plus 可见第 11 步
- 为 Hotmail / 2925 账号池复用同一套“添加账号 / 取消添加 / 批量导入 / 收起列表”表单交互;共享的显隐控制放在 `sidepanel/account-pool-ui.js`,各自 manager 只保留 provider 相关字段校验与业务操作
### 2.2 Background Service Worker
@@ -159,7 +160,7 @@
- Codex2API 配置
- IP 代理持久配置:`ipProxyEnabled`、服务商、模式、API 地址、服务商配置快照、账号列表、固定 Host / Port / Protocol / Username / Password、地区参数、session 与自动切换阈值
- Plus 模式开关 `plusModeEnabled`
- PayPal 登录配置 `paypalEmail / paypalPassword`
- PayPal 账号池配置 `paypalAccounts / currentPayPalAccountId`,以及供后台步骤兼容读取的 `paypalEmail / paypalPassword`
- 邮箱 provider 配置
- Hotmail 账号池
- 2925 账号池
@@ -485,6 +486,35 @@ Codex2API 补充:
- 步骤 10 会先尝试提交已捕获的 callback URL,随后轮询公开贡献状态,直到进入最终态
- `auto_approved``manual_review_required` 视为主流程完成;`auto_rejected / expired / error` 视为当前轮失败
## 6.1 Plus 模式链路
Plus 模式通过 `plusModeEnabled` 开启,目标是在普通注册资料完成后,不执行原 Step 6 Cookie 清理,也不执行原 Step 8 登录验证码步骤,而是先完成 Plus Checkout 与 PayPal 授权,再复用现有 OAuth 后半段。
Plus 模式可见步骤:
1. 第 1~5 步:沿用普通注册入口、邮箱、密码、注册验证码、资料填写链路。
2. 第 6 步 `创建 Plus Checkout`:打开已登录 ChatGPT 页面,通过 `/api/auth/session` 读取 accessToken,再请求 `https://chatgpt.com/backend-api/payments/checkout` 创建 `chatgptplusplan` 的 checkout session,并打开 `https://chatgpt.com/checkout/openai_ie/{checkout_session_id}`
3. 第 7 步 `填写账单并提交订阅`:选择 PayPal,生成账单全名,从 `data/address-sources.js` 读取同国家 seed query,触发 checkout 内置 Google 地址推荐,选择推荐项并校验地址第 1 行、城市、州/省、邮编等结构化字段,再点击“订阅”。运行时会按 Stripe iframe 拆分执行:付款方式在 `elements-inner-payment` frame,账单地址在 `elements-inner-address` frameGoogle 推荐在 `elements-inner-autocompl` frame 时单独点击推荐项。
4. 第 8 步 `PayPal 登录与授权`:后台优先读取侧边栏当前选中的 PayPal 账号;为兼容旧链路,也会把该账号同步回 `paypalEmail / paypalPassword`。当页面处于账号输入阶段时,内容脚本会固定对邮箱输入框执行 `focus -> clear -> fill -> blur -> click next`,即使文本框里已经预填了同一个账号,也会重新触发输入事件,避免 PayPal 因跳过重填而停在邮箱页;登录前固定等待 1 秒,关闭可见通行密钥提示,点击“同意并继续”。
5. 第 9 步 `订阅回跳确认`:等待 PayPal 授权后回跳到 ChatGPT / OpenAI 页面,页面加载完成后固定等待 1 秒。
6. 第 10 步:复用原 Step 7 OAuth 登录执行器,但状态和日志按 Plus 可见第 10 步记录。
7. 第 11 步:复用原 Step 8 登录验证码执行器,但状态和日志按 Plus 可见第 11 步记录。
8. 第 12 步:复用原 Step 9 OAuth 同意页点击和 localhost callback 捕获执行器,但状态和日志按 Plus 可见第 12 步记录。
9. 第 13 步:复用原 Step 10 CPA / SUB2API / Codex2API 平台回调验证执行器,但状态和日志按 Plus 可见第 13 步记录。
隐藏与跳过规则:
- 原 Step 6 `清理登录 Cookies`:Plus 模式下隐藏且不执行,因为 Plus Checkout 创建依赖当前 ChatGPT 登录态。
- 原 Step 8 `获取登录验证码`:Plus 模式下移动到可见第 11 步,继续负责登录验证码页的收码与提交。
- 原 Step 9 不能跳过;它负责点击 OAuth 同意页并捕获 `localhostUrl`
- 原 Step 10 不能跳过;它依赖 `localhostUrl` 完成平台侧账号创建或回调验证。
等待模型:
- Plus Checkout 和 PayPal 专用步骤使用“无限等待但可停止”的后台等待 helper。
- 每次页面加载完成后固定等待 1 秒,再继续下一次输入、点击或状态判断。
- 第一版不实时抓取外部地址网站;地址自动化只依赖本地 seed query 和 checkout 页面内置 Google 地址推荐。
## 7. 邮箱与 provider 链路
### 7.1 2026-04-17 补充:Gmail / 2925 统一别名邮箱链路
+14 -5
View File
@@ -29,6 +29,7 @@
- `mail-provider-utils.js`:网页邮箱 provider 配置纯工具,负责 `163 / 163 VIP / 126 / QQ / Inbucket / Hotmail` 的基础归一化与页面入口配置,并统一承接 iCloud 转发收码目标邮箱 provider 的归一化、选项列表和收码入口配置。
- `mail2925-utils.js`:2925 账号池相关的纯工具函数,负责账号归一化、冷却期判断、可用账号挑选、批量导入解析与列表更新。
- `managed-alias-utils.js`:共享 Gmail / 2925 的别名邮箱规则,负责解析基邮箱、校验“当前完整注册邮箱”是否仍与基邮箱兼容、生成 Gmail `+tag` 与 2925 随机后缀邮箱,并输出 sidepanel 可复用的 UI 文案;当前还统一承接 `mail2925Mode` 的归一化与“2925 仅在 provide 模式下参与别名生成”的共享判定。
- `paypal-utils.js`:PayPal 账号池相关的纯工具函数,负责账号归一化、按 ID 查找和列表 upsert。
- `manifest.json`:Chrome 扩展清单,声明权限、背景脚本、侧边栏、内容脚本与规则集;当前额外声明 `proxy / webRequest / webRequestAuthProvider`,用于 IP 代理 PAC 接管和代理鉴权回填。
- `microsoft-email.js`Microsoft Graph / Outlook 邮件读取辅助模块,负责刷新令牌换 token、邮箱夹轮询和验证码提取。
- `package.json`:仓库最小 Node 包配置,目前主要提供测试脚本定义。
@@ -50,8 +51,9 @@
- `background/ip-proxy-provider-711proxy.js`711Proxy provider 规则模块,负责从账号串中识别和写回 `region / session / sessTime` 等参数,并在固定账号模式下把侧栏配置转换为最终生效的代理账号。
- `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层;当前额外承接 `add-phone / 手机号页` 这类认证 fatal 错误的共享判定,并会把 Step 2 的“手机号输入模式未切成功”与真正的 auth `add-phone` 页面区分开,避免自动运行误停机。
- `background/mail-2925-session.js`:2925 会话模块,负责 2925 账号池持久化、当前账号切换、cookie 清理登出、自动登录、命中“子邮箱已达上限邮箱”后的 24 小时禁用与自动切号。
- `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息;当前额外接入 2925 账号池的新增、导入、切换、登录、禁用与删除消息,以及 IP 代理的同步、切换、Change 与出口检测消息。
- `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息;当前额外接入 PayPal 账号池的新增与切换、2925 账号池的新增、导入、切换、登录、禁用与删除消息,以及 IP 代理的同步、切换、Change 与出口检测消息。
- `background/navigation-utils.js`:导航与 URL 判断工具层,负责 callback、入口页、CPA / SUB2API / Codex2API 地址归一化、来源标签页家族判断与步骤跳转相关判断。
- `background/paypal-account-store.js`:PayPal 账号池持久化模块,负责保存账号列表、切换当前账号,并把当前选中账号同步回兼容字段 `paypalEmail / paypalPassword`
- `background/phone-verification-flow.js`:手机号验证共享流程模块,负责在 OAuth 链路命中 `add-phone / phone-verification` 页面后向 HeroSMS 申请或复用号码、轮询短信验证码、提交手机号码与短信验证码,并在号码长期收不到短信时把后续自动流拉回步骤 7 重新拿号。
- `background/panel-bridge.js`:来源桥接层;CPA / SUB2API 继续封装页面打开、脚本注入和通信,Codex2API 则直接通过后台协议生成 OAuth 地址。
- `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、等待密码页以及解析当前流程所用邮箱;在 Gmail 与 `2925 + provide` 模式下会优先复用已经存在且仍兼容的完整注册邮箱,只有不兼容或为空时才重新生成;当 provider 为 2925 且启用了 provide 模式下的号池时,会先确保账号池中已选中可用账号。
@@ -86,7 +88,7 @@
- `content/inbucket-mail.js`:Inbucket 邮箱轮询脚本,负责在 Inbucket 页面中读取、删除验证码邮件。
- `content/mail-163.js`163 / 163 VIP / 126 邮箱轮询脚本,负责网页邮箱验证码读取和邮件清理。
- `content/mail-2925.js`:2925 邮箱页面脚本,负责 2925 邮箱自动登录态确认、收件轮询、按步骤会话隔离“已试验证码”、在每次重发验证码之间执行一轮最多 15 次的邮箱刷新轮询、命中邮件后立即删当前邮件,以及在成功后配合后台执行整箱清理;若页面出现“子邮箱已达上限邮箱”提示,会立即上报后台进入切号链路;当后台显式开启 `mail2925MatchTargetEmail` 时,会对邮件里显式出现的目标邮箱做弱匹配,避免 `receive` 模式误捞别人的验证码。
- `content/paypal-flow.js`:PayPal 页面脚本,负责识别登录表单、填写 PayPal 账号密码、处理页面内可见通行密钥提示,并点击 PayPal 授权页的“同意并继续”按钮。
- `content/paypal-flow.js`:PayPal 页面脚本,负责识别登录表单、填写 PayPal 账号密码、在账号页固定执行 `focus -> clear -> fill -> blur` 重新触发邮箱输入事件、处理页面内可见通行密钥提示,并点击 PayPal 授权页的“同意并继续”按钮。
- `content/phone-auth.js`:认证页手机号验证脚本,负责识别 `add-phone / phone-verification` 页面、选择国家、填写手机号、提交短信验证码、触发重发短信,以及把“回到 add-phone / 进入 OAuth 同意页”的结果反馈给后台。
- `content/plus-checkout.js`ChatGPT Plus checkout 页面脚本,负责读取 `/api/auth/session` 创建 Plus checkout、选择 PayPal、填写账单姓名、触发 Google 地址推荐、校验结构化地址字段并点击订阅。
- `content/qq-mail.js`:QQ 邮箱轮询脚本,负责网页邮箱验证码读取。
@@ -130,6 +132,7 @@
- `sidepanel/icloud-manager.js`:侧边栏 iCloud 隐私邮箱管理器,负责列表、筛选、保留、删除和批量操作。
- `sidepanel/luckmail-manager.js`:侧边栏 LuckMail 管理器,负责邮箱列表、筛选、启停、保留与批量操作。
- `sidepanel/mail-2925-manager.js`:侧边栏 2925 账号池管理器,负责 2925 账号的新增、导入、切换、手动登录、启停、清冷却与删除。
- `sidepanel/form-dialog.js`:侧边栏公共表单弹窗模块,负责渲染可复用的小型表单弹窗,支持动态字段、校验、确认与取消。
- `sidepanel/account-records-manager.js`:侧边栏邮箱记录面板管理器,负责“记录”按钮、覆盖层开关、分页列表、成功/失败/停止统计摘要和清理确认。
- `sidepanel/contribution-content-update-service.js`:侧边栏贡献内容更新服务,负责拉取 `https://apikey.qzz.io/api/content-summary`、缓存公开公告/教程摘要,并输出可用于提示展示的 `promptVersion` 与最新更新时间。
- `sidepanel/contribution-mode.js`:侧边栏贡献模式管理器,负责顶部“贡献”按钮、确认弹窗、贡献模式显隐、复用主自动流程启动、侧栏内贡献状态轮询、上传页跳转,以及贡献模式下对来源选择、配置入口、记录入口和敏感配置行(包括 Codex2API 配置)的禁用与隐藏。
@@ -144,7 +147,8 @@
receive 模式把账号池开关一起隐藏;当前在 `邮箱生成` 区域新增 `自定义邮箱池` 选项和多行邮箱池输入框,并在 `邮箱服务 = 自定义邮箱`
额外显示 `自定义号池` 文本框;当邮箱服务为 iCloud 时,额外提供目标邮箱类型与转发邮箱 provider 配置,用于选择直接从 iCloud 收件箱收
码或从 QQ / 网易 / Gmail 转发目标邮箱收码;来源下拉框当前支持 `CPA / SUB2API / Codex2API`,其中 Codex2API 额外提供后台地址和管理密
钥配置行;设置卡片新增 `IP代理` 开关与代理配置折叠区,支持 711Proxy 账号密码模式、代理状态卡与出口检测按钮;设置卡片新增 `Plus 模式` 开关与 PayPal 账号/密码输入行Hotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器。
钥配置行;设置卡片新增 `IP代理` 开关与代理配置折叠区,支持 711Proxy 账号密码模式、代理状态卡与出口检测按钮;设置卡片新增 `Plus 模式` 开关与 PayPal 账号下拉框,右侧使用公共表单弹窗添加账号Hotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器。
- `sidepanel/paypal-manager.js`:侧边栏 PayPal 账号管理器,负责 Plus 模式下的账号下拉框渲染、添加账号弹窗、保存账号与切换当前账号。
- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、共享验证码自动重发次数配置与广播接收,并装
配 Hotmail / 2925 / iCloud / LuckMail / 贡献模式 / 邮箱记录面板 / 贡献内容更新服务;当前贡献模式的“开始贡献”会直接复用主自动流程启
动逻辑,而独立 manager 负责贡献运行态展示与轮询,同时把 Gmail / 2925 的基邮箱输入、完整注册邮箱输入、自动生成按钮与兼容性校验统一接
@@ -152,8 +156,9 @@
示并同时服务于 provide / receive 两种模式;当 provider 为 iCloud 时,会保存并回显目标邮箱类型与转发邮箱 provider,相关 provider 选
项和 label 复用 `mail-provider-utils.js``自定义邮箱池` 模式会按邮箱池长度锁定自动轮数,并把当前输入的邮箱池配置参与自动启动前保
存;`邮箱服务 = 自定义邮箱` 时,如果配置了 `customMailProviderPool`,也会按号池长度锁定自动轮数并在 Auto 中按轮次分配注册邮箱,同时
在普通失败时继续复用当前邮箱,只有成功或出现手机号验证时才切换下一个邮箱;Plus 模式开启后,步骤列表会切换为 13 步,并显示 PayPal
据配置;Step 8 的自定义邮箱确认弹窗当前额外提供“出现手机号验证”按钮,用于直接走与真实 add-phone 一致的 fatal 分支;HeroSMS 国家列表
在普通失败时继续复用当前邮箱,只有成功或出现手机号验证时才切换下一个邮箱;Plus 模式开启后,步骤列表会切换为 13 步,并显示 PayPal
号下拉框与添加入口;当前选中的 PayPal 账号会同步回兼容字段 `paypalEmail / paypalPassword` 供后台步骤复用;Step 8 的自定义邮箱确认弹
窗当前额外提供“出现手机号验证”按钮,用于直接走与真实 add-phone 一致的 fatal 分支;HeroSMS 国家列表
会在 sidepanel 初始化时拉取并恢复到本地保存的国家/API 设置,接码开关关闭时会直接隐藏相关配置;账号记录快照同步改为默认自动模式,只
要本地 helper 可用就会自动落盘;新来源 Codex2API 在这里仅补充来源配置接线、表单显隐和 Step 10 按钮文案,不承接协议业务逻辑;
Hotmail / 2925 的新增表单显隐统一接到 `sidepanel/account-pool-ui.js` 这一层共享 helper;IP 代理配置保存、服务配置快照、开关应用和运行态回显由这里接入消息路由,具体解析/状态渲染下沉到 `sidepanel/ip-proxy-panel.js`;侧边栏初始化与点击“自动”前会刷新一次贡献站
@@ -189,6 +194,7 @@
- `tests/background-message-router-step2-skip.test.js`:测试步骤 2 直接落到验证码页时的跳步与状态保护逻辑。
- `tests/background-navigation-utils-module.test.js`:测试导航工具模块已接入且导出工厂。
- `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-signup-flow-module.test.js`:测试注册页辅助模块已接入且导出工厂。
- `tests/background-skip-step-linking.test.js`:测试手动跳过步骤 1 时,会级联跳过步骤 2~5,并仅跳过其中未完成且未运行的步骤。
@@ -202,6 +208,8 @@
- `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/paypal-approve-detection.test.js`:测试 Plus 第 8 步后台执行器对 PayPal 标签页发现、分离式账号/密码页识别、联合登录页识别,以及登录后直接离开 PayPal 页的分支判断。
- `tests/paypal-flow-content.test.js`:测试 PayPal 内容脚本对可见邮箱/密码输入框的识别,并覆盖邮箱页即使已预填相同账号也会先清空再重填后继续下一步。
- `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 的轮询与目标邮箱选择逻辑。
- `tests/cloudflare-temp-email-utils.test.js`:测试 Cloudflare Temp Email 工具层的 URL、域名、邮件解析逻辑。
@@ -229,6 +237,7 @@
- `tests/sidepanel-luckmail-manager.test.js`:测试侧边栏 LuckMail 管理器模块接线与空态渲染。
- `tests/sidepanel-mail2925-manager.test.js`:测试侧边栏 2925 管理器模块接线、共享号池表单脚本加载顺序、显隐交互与空态渲染。
- `tests/sidepanel-mail2925-mode.test.js`:测试侧边栏保留 `2925 provide / receive` 模式行与独立的号池配置行,并验证 sidepanel 只在 provide 模式下把 2925 视为别名邮箱 provider。
- `tests/sidepanel-paypal-manager.test.js`:测试侧边栏公共表单弹窗脚本与 PayPal manager 的加载顺序,以及 PayPal 账号保存后会立即选中当前账号。
- `tests/signup-entry-diagnostics.test.js`:测试注册入口诊断快照输出。
- `tests/signup-step2-email-switch.test.js`:测试 Step 2 在手机号输入模式下切回邮箱输入模式,以及本地化邮箱输入框识别。
- `tests/signup-page-tab-cleanup.test.js`:测试注册页来源标签的冲突清理逻辑。