-
@@ -722,6 +727,7 @@
+
diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js
index f809d69..f99ba4c 100644
--- a/sidepanel/sidepanel.js
+++ b/sidepanel/sidepanel.js
@@ -148,10 +148,12 @@ const inputHotmailRefreshToken = document.getElementById('input-hotmail-refresh-
const inputHotmailImport = document.getElementById('input-hotmail-import');
const btnAddHotmailAccount = document.getElementById('btn-add-hotmail-account');
const btnImportHotmailAccounts = document.getElementById('btn-import-hotmail-accounts');
+const btnToggleHotmailForm = document.getElementById('btn-toggle-hotmail-form');
const btnHotmailUsageGuide = document.getElementById('btn-hotmail-usage-guide');
const btnClearUsedHotmailAccounts = document.getElementById('btn-clear-used-hotmail-accounts');
const btnDeleteAllHotmailAccounts = document.getElementById('btn-delete-all-hotmail-accounts');
const btnToggleHotmailList = document.getElementById('btn-toggle-hotmail-list');
+const hotmailFormShell = document.getElementById('hotmail-form-shell');
const hotmailListShell = document.getElementById('hotmail-list-shell');
const hotmailAccountsList = document.getElementById('hotmail-accounts-list');
const inputMail2925Email = document.getElementById('input-mail2925-email');
@@ -3086,7 +3088,9 @@ const hotmailManager = window.SidepanelHotmailManager?.createHotmailManager({
btnDeleteAllHotmailAccounts,
btnHotmailUsageGuide,
btnImportHotmailAccounts,
+ btnToggleHotmailForm,
btnToggleHotmailList,
+ hotmailFormShell,
hotmailAccountsList,
hotmailListShell,
inputEmail,
diff --git a/tests/background-icloud-mail-provider.test.js b/tests/background-icloud-mail-provider.test.js
index 42e6d3b..346a523 100644
--- a/tests/background-icloud-mail-provider.test.js
+++ b/tests/background-icloud-mail-provider.test.js
@@ -143,3 +143,32 @@ return { getMailConfig };
navigateOnReuse: true,
});
});
+
+test('getMailConfig keeps provider metadata for 2925 mailboxes', () => {
+ const bundle = extractFunction('getMailConfig');
+ const api = new Function(`
+const ICLOUD_PROVIDER = 'icloud';
+const GMAIL_PROVIDER = 'gmail';
+const HOTMAIL_PROVIDER = 'hotmail-api';
+const LUCKMAIL_PROVIDER = 'luckmail-api';
+const CLOUDFLARE_TEMP_EMAIL_PROVIDER = 'cloudflare-temp-email';
+function normalizeIcloudHost(value = '') { return String(value || '').trim().toLowerCase(); }
+function normalizeInbucketOrigin(value) { return String(value || '').trim(); }
+function getConfiguredIcloudHostPreference() { return ''; }
+function getIcloudLoginUrlForHost(host) { return host; }
+function getIcloudMailUrlForHost(host) { return host; }
+${bundle}
+return { getMailConfig };
+`)();
+
+ assert.deepEqual(api.getMailConfig({
+ mailProvider: '2925',
+ }), {
+ provider: '2925',
+ source: 'mail-2925',
+ url: 'https://2925.com/#/mailList',
+ label: '2925 邮箱',
+ inject: ['content/utils.js', 'content/mail-2925.js'],
+ injectSource: 'mail-2925',
+ });
+});
diff --git a/tests/background-mail2925-entry-behavior.test.js b/tests/background-mail2925-entry-behavior.test.js
new file mode 100644
index 0000000..dbec938
--- /dev/null
+++ b/tests/background-mail2925-entry-behavior.test.js
@@ -0,0 +1,207 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const mail2925Utils = require('../mail2925-utils.js');
+
+const source = fs.readFileSync('background/mail-2925-session.js', 'utf8');
+const globalScope = {};
+const api = new Function('self', `${source}; return self.MultiPageBackgroundMail2925Session;`)(globalScope);
+
+test('ensureMail2925MailboxSession reuses current mailbox page without sending login when page stays on mailList', async () => {
+ let currentState = {
+ autoRunning: false,
+ mail2925Accounts: mail2925Utils.normalizeMail2925Accounts([
+ { id: 'acc-1', email: 'acc1@2925.com', password: 'p1', enabled: true, lastUsedAt: 10 },
+ ]),
+ currentMail2925AccountId: 'acc-1',
+ };
+ let sendCalls = 0;
+ let readyCalls = 0;
+
+ const manager = api.createMail2925SessionManager({
+ addLog: async () => {},
+ broadcastDataUpdate: () => {},
+ chrome: {
+ tabs: {
+ get: async () => ({ id: 9, url: 'https://2925.com/#/mailList' }),
+ },
+ cookies: {
+ getAll: async () => [],
+ remove: async () => ({ ok: true }),
+ },
+ browsingData: {
+ removeCookies: async () => {},
+ },
+ },
+ ensureContentScriptReadyOnTab: async () => {
+ readyCalls += 1;
+ },
+ findMail2925Account: mail2925Utils.findMail2925Account,
+ getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus,
+ getState: async () => currentState,
+ isAutoRunLockedState: () => false,
+ isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable,
+ MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS,
+ normalizeMail2925Account: mail2925Utils.normalizeMail2925Account,
+ normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts,
+ pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun,
+ reuseOrCreateTab: async () => 9,
+ sendToMailContentScriptResilient: async () => {
+ sendCalls += 1;
+ return { loggedIn: true };
+ },
+ setPersistentSettings: async (payload) => {
+ currentState = { ...currentState, ...payload };
+ },
+ setState: async (updates) => {
+ currentState = { ...currentState, ...updates };
+ },
+ throwIfStopped: () => {},
+ upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList,
+ });
+
+ const result = await manager.ensureMail2925MailboxSession({
+ accountId: 'acc-1',
+ forceRelogin: false,
+ allowLoginWhenOnLoginPage: true,
+ actionLabel: '步骤 8:确认 2925 邮箱登录态',
+ });
+
+ assert.equal(sendCalls, 0);
+ assert.equal(readyCalls, 0);
+ assert.equal(result.result.usedExistingSession, true);
+});
+
+test('ensureMail2925MailboxSession stops immediately when login page is detected and account pool is off', async () => {
+ let currentState = {
+ autoRunning: true,
+ autoRunPhase: 'running',
+ mail2925Accounts: mail2925Utils.normalizeMail2925Accounts([
+ { id: 'acc-1', email: 'acc1@2925.com', password: 'p1', enabled: true, lastUsedAt: 10 },
+ ]),
+ currentMail2925AccountId: 'acc-1',
+ };
+ const stopCalls = [];
+ let sendCalls = 0;
+
+ const manager = api.createMail2925SessionManager({
+ addLog: async () => {},
+ broadcastDataUpdate: () => {},
+ chrome: {
+ tabs: {
+ get: async () => ({ id: 9, url: 'https://2925.com/login/' }),
+ },
+ cookies: {
+ getAll: async () => [],
+ remove: async () => ({ ok: true }),
+ },
+ browsingData: {
+ removeCookies: async () => {},
+ },
+ },
+ ensureContentScriptReadyOnTab: async () => {},
+ findMail2925Account: mail2925Utils.findMail2925Account,
+ getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus,
+ getState: async () => currentState,
+ isAutoRunLockedState: (state) => Boolean(state?.autoRunning) && state?.autoRunPhase === 'running',
+ isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable,
+ MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS,
+ normalizeMail2925Account: mail2925Utils.normalizeMail2925Account,
+ normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts,
+ pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun,
+ requestStop: async (options = {}) => {
+ stopCalls.push(options);
+ },
+ reuseOrCreateTab: async () => 9,
+ sendToMailContentScriptResilient: async () => {
+ sendCalls += 1;
+ return { loggedIn: true };
+ },
+ setPersistentSettings: async (payload) => {
+ currentState = { ...currentState, ...payload };
+ },
+ setState: async (updates) => {
+ currentState = { ...currentState, ...updates };
+ },
+ throwIfStopped: () => {},
+ upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList,
+ });
+
+ await assert.rejects(
+ () => manager.ensureMail2925MailboxSession({
+ accountId: 'acc-1',
+ forceRelogin: false,
+ allowLoginWhenOnLoginPage: false,
+ actionLabel: '步骤 4:确认 2925 邮箱登录态',
+ }),
+ /流程已被用户停止。/
+ );
+
+ assert.equal(sendCalls, 0);
+ assert.equal(stopCalls.length, 1);
+});
+
+test('ensureMail2925MailboxSession logs in when login page is detected and account pool is on', async () => {
+ let currentState = {
+ autoRunning: false,
+ mail2925Accounts: mail2925Utils.normalizeMail2925Accounts([
+ { id: 'acc-1', email: 'acc1@2925.com', password: 'p1', enabled: true, lastUsedAt: 10 },
+ ]),
+ currentMail2925AccountId: 'acc-1',
+ };
+ let sendCalls = 0;
+ let readyCalls = 0;
+
+ const manager = api.createMail2925SessionManager({
+ addLog: async () => {},
+ broadcastDataUpdate: () => {},
+ chrome: {
+ tabs: {
+ get: async () => ({ id: 9, url: 'https://2925.com/login/' }),
+ },
+ cookies: {
+ getAll: async () => [],
+ remove: async () => ({ ok: true }),
+ },
+ browsingData: {
+ removeCookies: async () => {},
+ },
+ },
+ ensureContentScriptReadyOnTab: async () => {
+ readyCalls += 1;
+ },
+ findMail2925Account: mail2925Utils.findMail2925Account,
+ getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus,
+ getState: async () => currentState,
+ isAutoRunLockedState: () => false,
+ isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable,
+ MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS,
+ normalizeMail2925Account: mail2925Utils.normalizeMail2925Account,
+ normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts,
+ pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun,
+ reuseOrCreateTab: async () => 9,
+ sendToMailContentScriptResilient: async () => {
+ sendCalls += 1;
+ return { loggedIn: true, currentView: 'mailbox' };
+ },
+ setPersistentSettings: async (payload) => {
+ currentState = { ...currentState, ...payload };
+ },
+ setState: async (updates) => {
+ currentState = { ...currentState, ...updates };
+ },
+ throwIfStopped: () => {},
+ upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList,
+ });
+
+ const result = await manager.ensureMail2925MailboxSession({
+ accountId: 'acc-1',
+ forceRelogin: false,
+ allowLoginWhenOnLoginPage: true,
+ actionLabel: '步骤 4:确认 2925 邮箱登录态',
+ });
+
+ assert.equal(sendCalls, 1);
+ assert.equal(readyCalls, 1);
+ assert.equal(result.result.loggedIn, true);
+});
diff --git a/tests/background-mail2925-relogin-wait.test.js b/tests/background-mail2925-relogin-wait.test.js
index 1f3cd4a..c95d9f1 100644
--- a/tests/background-mail2925-relogin-wait.test.js
+++ b/tests/background-mail2925-relogin-wait.test.js
@@ -78,5 +78,5 @@ test('ensureMail2925MailboxSession waits 3 seconds before and after opening logi
assert.deepStrictEqual(events.openedUrls, ['https://2925.com/login/']);
assert.deepStrictEqual(events.sleeps, [3000, 3000]);
- assert.equal(events.readyCalls, 1);
+ assert.equal(events.readyCalls, 2);
});
diff --git a/tests/background-mail2925-session-module.test.js b/tests/background-mail2925-session-module.test.js
index c0322fa..714c9ba 100644
--- a/tests/background-mail2925-session-module.test.js
+++ b/tests/background-mail2925-session-module.test.js
@@ -223,5 +223,5 @@ test('ensureMail2925MailboxSession requests stop when auto run is active and log
);
assert.equal(events.stopCalls.length, 1);
- assert.match(events.stopCalls[0].logMessage, /20 秒内未进入收件箱/);
+ assert.match(events.stopCalls[0].logMessage, /登录后仍未进入收件箱/);
});
diff --git a/tests/background-step4-filter-window.test.js b/tests/background-step4-filter-window.test.js
index cced4bc..a798811 100644
--- a/tests/background-step4-filter-window.test.js
+++ b/tests/background-step4-filter-window.test.js
@@ -8,6 +8,9 @@ const api = new Function('self', `${source}; return self.MultiPageBackgroundStep
test('step 4 passes a fixed 10-minute lookback window to 2925 mailbox polling', async () => {
let capturedOptions = null;
+ let ensureCalls = 0;
+ const tabUpdates = [];
+ const tabReuses = [];
const realDateNow = Date.now;
Date.now = () => 700000;
@@ -15,12 +18,16 @@ test('step 4 passes a fixed 10-minute lookback window to 2925 mailbox polling',
addLog: async () => {},
chrome: {
tabs: {
- update: async () => {},
+ update: async (tabId, payload) => {
+ tabUpdates.push({ tabId, payload });
+ },
},
},
completeStepFromBackground: async () => {},
confirmCustomVerificationStepBypass: async () => {},
- ensureMail2925MailboxSession: async () => {},
+ ensureMail2925MailboxSession: async () => {
+ ensureCalls += 1;
+ },
getMailConfig: () => ({
provider: '2925',
label: '2925 邮箱',
@@ -35,7 +42,9 @@ test('step 4 passes a fixed 10-minute lookback window to 2925 mailbox polling',
resolveVerificationStep: async (_step, _state, _mail, options) => {
capturedOptions = options;
},
- reuseOrCreateTab: async () => {},
+ reuseOrCreateTab: async (source, url) => {
+ tabReuses.push({ source, url });
+ },
sendToContentScriptResilient: async () => ({}),
shouldUseCustomRegistrationEmail: () => false,
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
@@ -46,12 +55,17 @@ test('step 4 passes a fixed 10-minute lookback window to 2925 mailbox polling',
await executor.executeStep4({
email: 'user@example.com',
password: 'secret',
- mail2925UseAccountPool: false,
+ mail2925UseAccountPool: true,
});
} finally {
Date.now = realDateNow;
}
+ assert.equal(ensureCalls, 1);
+ assert.deepStrictEqual(tabReuses, []);
+ assert.deepStrictEqual(tabUpdates, [
+ { tabId: 1, payload: { active: true } },
+ ]);
assert.equal(capturedOptions.filterAfterTimestamp, 100000);
assert.equal(capturedOptions.resendIntervalMs, 0);
});
diff --git a/tests/background-step7-recovery.test.js b/tests/background-step7-recovery.test.js
index 2503e7a..0462f6d 100644
--- a/tests/background-step7-recovery.test.js
+++ b/tests/background-step7-recovery.test.js
@@ -91,6 +91,9 @@ test('step 8 submits login verification directly without replaying step 7', asyn
test('step 8 uses a fixed 10-minute lookback window and disables resend interval for 2925 mailbox polling', async () => {
let capturedOptions = null;
+ let ensureCalls = 0;
+ const tabUpdates = [];
+ const tabReuses = [];
const realDateNow = Date.now;
Date.now = () => 900000;
@@ -98,11 +101,16 @@ test('step 8 uses a fixed 10-minute lookback window and disables resend interval
addLog: async () => {},
chrome: {
tabs: {
- update: async () => {},
+ update: async (tabId, payload) => {
+ tabUpdates.push({ tabId, payload });
+ },
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
confirmCustomVerificationStepBypass: async () => {},
+ ensureMail2925MailboxSession: async () => {
+ ensureCalls += 1;
+ },
ensureStep8VerificationPageReady: async () => ({ state: 'verification_page' }),
rerunStep7ForStep8Recovery: async () => {},
getOAuthFlowRemainingMs: async () => 8000,
@@ -123,7 +131,9 @@ test('step 8 uses a fixed 10-minute lookback window and disables resend interval
resolveVerificationStep: async (_step, _state, _mail, options) => {
capturedOptions = options;
},
- reuseOrCreateTab: async () => {},
+ reuseOrCreateTab: async (source, url) => {
+ tabReuses.push({ source, url });
+ },
setState: async () => {},
setStepStatus: async () => {},
shouldUseCustomRegistrationEmail: () => false,
@@ -137,11 +147,18 @@ test('step 8 uses a fixed 10-minute lookback window and disables resend interval
email: 'user@example.com',
password: 'secret',
oauthUrl: 'https://oauth.example/latest',
+ mail2925UseAccountPool: true,
});
} finally {
Date.now = realDateNow;
}
+ assert.equal(ensureCalls, 0);
+ assert.deepStrictEqual(tabReuses, []);
+ assert.deepStrictEqual(tabUpdates, [
+ { tabId: 1, payload: { active: true } },
+ { tabId: 2, payload: { active: true } },
+ ]);
assert.equal(capturedOptions.filterAfterTimestamp, 300000);
assert.equal(capturedOptions.resendIntervalMs, 0);
assert.equal(capturedOptions.targetEmail, '');
diff --git a/tests/sidepanel-hotmail-manager.test.js b/tests/sidepanel-hotmail-manager.test.js
index bd0dbec..bbed15a 100644
--- a/tests/sidepanel-hotmail-manager.test.js
+++ b/tests/sidepanel-hotmail-manager.test.js
@@ -2,19 +2,74 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
+function createAccountPoolUiStub() {
+ return {
+ createAccountPoolFormController({
+ formShell,
+ toggleButton,
+ hiddenLabel = '添加账号',
+ visibleLabel = '取消添加',
+ onClear,
+ onFocus,
+ } = {}) {
+ let visible = false;
+
+ function sync() {
+ if (formShell) {
+ formShell.hidden = !visible;
+ }
+ if (toggleButton) {
+ toggleButton.textContent = visible ? visibleLabel : hiddenLabel;
+ toggleButton.setAttribute?.('aria-expanded', String(visible));
+ }
+ }
+
+ function setVisible(nextVisible, options = {}) {
+ visible = Boolean(nextVisible);
+ if (options.clearForm) {
+ onClear?.();
+ }
+ sync();
+ if (visible && options.focusField) {
+ onFocus?.();
+ }
+ }
+
+ sync();
+ return {
+ isVisible: () => visible,
+ setVisible,
+ sync,
+ };
+ },
+ };
+}
+
test('sidepanel loads hotmail manager before sidepanel bootstrap', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
+ const helperIndex = html.indexOf('');
const hotmailManagerIndex = html.indexOf('');
const sidepanelIndex = html.indexOf('');
+ assert.notEqual(helperIndex, -1);
assert.notEqual(hotmailManagerIndex, -1);
assert.notEqual(sidepanelIndex, -1);
+ assert.ok(helperIndex < hotmailManagerIndex);
assert.ok(hotmailManagerIndex < sidepanelIndex);
});
+test('sidepanel html contains collapsible hotmail form controls', () => {
+ const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
+ assert.match(html, /id="btn-toggle-hotmail-form"/);
+ assert.match(html, /id="hotmail-form-shell"/);
+ assert.match(html, /id="btn-import-hotmail-accounts"[^>]*>批量导入);
+});
+
test('hotmail manager exposes a factory and renders empty state', () => {
const source = fs.readFileSync('sidepanel/hotmail-manager.js', 'utf8');
- const windowObject = {};
+ const windowObject = {
+ SidepanelAccountPoolUi: createAccountPoolUiStub(),
+ };
const localStorageMock = {
getItem() {
return null;
@@ -77,3 +132,199 @@ test('hotmail manager exposes a factory and renders empty state', () => {
manager.renderHotmailAccounts();
assert.match(hotmailAccountsList.innerHTML, /还没有 Hotmail 账号/);
});
+
+test('hotmail manager toggles form container from header button', () => {
+ const source = fs.readFileSync('sidepanel/hotmail-manager.js', 'utf8');
+ const windowObject = {
+ SidepanelAccountPoolUi: createAccountPoolUiStub(),
+ };
+ const localStorageMock = {
+ getItem() {
+ return null;
+ },
+ setItem() {},
+ };
+
+ const api = new Function('window', 'localStorage', `${source}; return window.SidepanelHotmailManager;`)(
+ windowObject,
+ localStorageMock
+ );
+
+ const handlers = {};
+ const toggleFormButton = {
+ textContent: '',
+ disabled: false,
+ setAttribute(name, value) {
+ this[name] = value;
+ },
+ addEventListener(type, handler) {
+ if (type === 'click') handlers.toggleForm = handler;
+ },
+ };
+ const formShell = { hidden: true };
+
+ const manager = api.createHotmailManager({
+ state: {
+ getLatestState: () => ({ currentHotmailAccountId: null }),
+ syncLatestState() {},
+ },
+ dom: {
+ btnAddHotmailAccount: { textContent: '', disabled: false, addEventListener() {} },
+ btnClearUsedHotmailAccounts: { textContent: '', disabled: false, addEventListener() {} },
+ btnDeleteAllHotmailAccounts: { textContent: '', disabled: false, addEventListener() {} },
+ btnHotmailUsageGuide: { addEventListener() {} },
+ btnImportHotmailAccounts: { disabled: false, addEventListener() {} },
+ btnToggleHotmailForm: toggleFormButton,
+ btnToggleHotmailList: { textContent: '', disabled: false, setAttribute() {}, addEventListener() {} },
+ hotmailAccountsList: { innerHTML: '', addEventListener() {} },
+ hotmailFormShell: formShell,
+ hotmailListShell: { classList: { toggle() {} } },
+ inputEmail: { value: '' },
+ inputHotmailClientId: { value: '' },
+ inputHotmailEmail: { value: '', focus() { this.focused = true; } },
+ inputHotmailImport: { value: '' },
+ inputHotmailPassword: { value: '' },
+ inputHotmailRefreshToken: { value: '' },
+ selectMailProvider: { value: 'hotmail-api' },
+ },
+ helpers: {
+ getHotmailAccounts: () => [],
+ getCurrentHotmailEmail: () => '',
+ escapeHtml: (value) => String(value || ''),
+ showToast() {},
+ openConfirmModal: async () => true,
+ copyTextToClipboard: async () => {},
+ },
+ runtime: {
+ sendMessage: async () => ({}),
+ },
+ constants: {
+ copyIcon: '',
+ displayTimeZone: 'Asia/Shanghai',
+ expandedStorageKey: 'multipage-hotmail-list-expanded',
+ },
+ hotmailUtils: {},
+ });
+
+ manager.bindHotmailEvents();
+ assert.equal(formShell.hidden, true);
+ assert.equal(toggleFormButton.textContent, '添加账号');
+
+ handlers.toggleForm();
+ assert.equal(formShell.hidden, false);
+ assert.equal(toggleFormButton.textContent, '取消添加');
+
+ handlers.toggleForm();
+ assert.equal(formShell.hidden, true);
+ assert.equal(toggleFormButton.textContent, '添加账号');
+});
+
+test('hotmail manager hides form after save succeeds', async () => {
+ const source = fs.readFileSync('sidepanel/hotmail-manager.js', 'utf8');
+ const windowObject = {
+ SidepanelAccountPoolUi: createAccountPoolUiStub(),
+ };
+ const localStorageMock = {
+ getItem() {
+ return null;
+ },
+ setItem() {},
+ };
+
+ const api = new Function('window', 'localStorage', `${source}; return window.SidepanelHotmailManager;`)(
+ windowObject,
+ localStorageMock
+ );
+
+ const handlers = {};
+ const formShell = { hidden: true };
+ const toggleFormButton = {
+ textContent: '',
+ disabled: false,
+ setAttribute() {},
+ addEventListener(type, handler) {
+ if (type === 'click') handlers.toggleForm = handler;
+ },
+ };
+ const addButton = {
+ textContent: '',
+ disabled: false,
+ addEventListener(type, handler) {
+ if (type === 'click') handlers.add = handler;
+ },
+ };
+ const inputHotmailEmail = { value: '', focus() {} };
+ const inputHotmailClientId = { value: '' };
+ const inputHotmailPassword = { value: '' };
+ const inputHotmailRefreshToken = { value: '' };
+ const toastMessages = [];
+
+ const manager = api.createHotmailManager({
+ state: {
+ getLatestState: () => ({ currentHotmailAccountId: null }),
+ syncLatestState() {},
+ },
+ dom: {
+ btnAddHotmailAccount: addButton,
+ btnClearUsedHotmailAccounts: { textContent: '', disabled: false, addEventListener() {} },
+ btnDeleteAllHotmailAccounts: { textContent: '', disabled: false, addEventListener() {} },
+ btnHotmailUsageGuide: { addEventListener() {} },
+ btnImportHotmailAccounts: { disabled: false, addEventListener() {} },
+ btnToggleHotmailForm: toggleFormButton,
+ btnToggleHotmailList: { textContent: '', disabled: false, setAttribute() {}, addEventListener() {} },
+ hotmailAccountsList: { innerHTML: '', addEventListener() {} },
+ hotmailFormShell: formShell,
+ hotmailListShell: { classList: { toggle() {} } },
+ inputEmail: { value: '' },
+ inputHotmailClientId,
+ inputHotmailEmail,
+ inputHotmailImport: { value: '' },
+ inputHotmailPassword,
+ inputHotmailRefreshToken,
+ selectMailProvider: { value: 'hotmail-api' },
+ },
+ helpers: {
+ getHotmailAccounts: () => [],
+ getCurrentHotmailEmail: () => '',
+ escapeHtml: (value) => String(value || ''),
+ showToast(message) {
+ toastMessages.push(message);
+ },
+ openConfirmModal: async () => true,
+ copyTextToClipboard: async () => {},
+ },
+ runtime: {
+ sendMessage: async () => ({
+ account: {
+ id: 'acc-1',
+ email: 'demo@hotmail.com',
+ clientId: 'client-id',
+ refreshToken: 'refresh-token',
+ },
+ }),
+ },
+ constants: {
+ copyIcon: '',
+ displayTimeZone: 'Asia/Shanghai',
+ expandedStorageKey: 'multipage-hotmail-list-expanded',
+ },
+ hotmailUtils: {},
+ });
+
+ manager.bindHotmailEvents();
+ handlers.toggleForm();
+ inputHotmailEmail.value = 'demo@hotmail.com';
+ inputHotmailClientId.value = 'client-id';
+ inputHotmailPassword.value = 'secret';
+ inputHotmailRefreshToken.value = 'refresh-token';
+
+ await handlers.add();
+
+ assert.equal(formShell.hidden, true);
+ assert.equal(toggleFormButton.textContent, '添加账号');
+ assert.equal(inputHotmailEmail.value, '');
+ assert.equal(inputHotmailClientId.value, '');
+ assert.equal(inputHotmailPassword.value, '');
+ assert.equal(inputHotmailRefreshToken.value, '');
+ assert.match(toastMessages.at(-1) || '', /已保存 Hotmail 账号/);
+});
diff --git a/tests/sidepanel-mail2925-edit.test.js b/tests/sidepanel-mail2925-edit.test.js
index f7dac78..20ed653 100644
--- a/tests/sidepanel-mail2925-edit.test.js
+++ b/tests/sidepanel-mail2925-edit.test.js
@@ -2,6 +2,49 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
+function createAccountPoolUiStub() {
+ return {
+ createAccountPoolFormController({
+ formShell,
+ toggleButton,
+ hiddenLabel = '添加账号',
+ visibleLabel = '取消添加',
+ onClear,
+ onFocus,
+ } = {}) {
+ let visible = false;
+
+ function sync() {
+ if (formShell) {
+ formShell.hidden = !visible;
+ }
+ if (toggleButton) {
+ toggleButton.textContent = visible ? visibleLabel : hiddenLabel;
+ toggleButton.setAttribute?.('aria-expanded', String(visible));
+ }
+ }
+
+ function setVisible(nextVisible, options = {}) {
+ visible = Boolean(nextVisible);
+ if (options.clearForm) {
+ onClear?.();
+ }
+ sync();
+ if (visible && options.focusField) {
+ onFocus?.();
+ }
+ }
+
+ sync();
+ return {
+ isVisible: () => visible,
+ setVisible,
+ sync,
+ };
+ },
+ };
+}
+
test('sidepanel html contains collapsible mail2925 form controls', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
assert.match(html, /id="btn-toggle-mail2925-form"/);
@@ -11,7 +54,9 @@ test('sidepanel html contains collapsible mail2925 form controls', () => {
test('mail2925 manager renders edit action for existing accounts', () => {
const source = fs.readFileSync('sidepanel/mail-2925-manager.js', 'utf8');
- const windowObject = {};
+ const windowObject = {
+ SidepanelAccountPoolUi: createAccountPoolUiStub(),
+ };
const localStorageMock = {
getItem() {
return null;
diff --git a/tests/sidepanel-mail2925-manager.test.js b/tests/sidepanel-mail2925-manager.test.js
index a51d061..7acff9f 100644
--- a/tests/sidepanel-mail2925-manager.test.js
+++ b/tests/sidepanel-mail2925-manager.test.js
@@ -2,13 +2,59 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
+function createAccountPoolUiStub() {
+ return {
+ createAccountPoolFormController({
+ formShell,
+ toggleButton,
+ hiddenLabel = '添加账号',
+ visibleLabel = '取消添加',
+ onClear,
+ onFocus,
+ } = {}) {
+ let visible = false;
+
+ function sync() {
+ if (formShell) {
+ formShell.hidden = !visible;
+ }
+ if (toggleButton) {
+ toggleButton.textContent = visible ? visibleLabel : hiddenLabel;
+ toggleButton.setAttribute?.('aria-expanded', String(visible));
+ }
+ }
+
+ function setVisible(nextVisible, options = {}) {
+ visible = Boolean(nextVisible);
+ if (options.clearForm) {
+ onClear?.();
+ }
+ sync();
+ if (visible && options.focusField) {
+ onFocus?.();
+ }
+ }
+
+ sync();
+ return {
+ isVisible: () => visible,
+ setVisible,
+ sync,
+ };
+ },
+ };
+}
+
test('sidepanel loads mail2925 manager before sidepanel bootstrap', () => {
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
+ const helperIndex = html.indexOf('');
const managerIndex = html.indexOf('');
const sidepanelIndex = html.indexOf('');
+ assert.notEqual(helperIndex, -1);
assert.notEqual(managerIndex, -1);
assert.notEqual(sidepanelIndex, -1);
+ assert.ok(helperIndex < managerIndex);
assert.ok(managerIndex < sidepanelIndex);
});
@@ -22,9 +68,16 @@ test('sidepanel html contains mail2925 pool toggle and selector controls', () =>
assert.doesNotMatch(html, /id="btn-cancel-mail2925-edit"/);
});
+test('sidepanel css keeps collapsed shared mailbox list near a single card height', () => {
+ const css = fs.readFileSync('sidepanel/sidepanel.css', 'utf8');
+ assert.match(css, /\.hotmail-list-shell\.is-collapsed\s*\{\s*max-height:\s*176px;\s*\}/);
+});
+
test('mail2925 manager exposes a factory and renders empty state', () => {
const source = fs.readFileSync('sidepanel/mail-2925-manager.js', 'utf8');
- const windowObject = {};
+ const windowObject = {
+ SidepanelAccountPoolUi: createAccountPoolUiStub(),
+ };
const localStorageMock = {
getItem() {
return null;
@@ -101,7 +154,9 @@ test('mail2925 manager exposes a factory and renders empty state', () => {
test('mail2925 manager toggles form container from header button', () => {
const source = fs.readFileSync('sidepanel/mail-2925-manager.js', 'utf8');
- const windowObject = {};
+ const windowObject = {
+ SidepanelAccountPoolUi: createAccountPoolUiStub(),
+ };
const localStorageMock = {
getItem() {
return null;
@@ -179,7 +234,9 @@ test('mail2925 manager toggles form container from header button', () => {
test('mail2925 manager hides form after save succeeds', async () => {
const source = fs.readFileSync('sidepanel/mail-2925-manager.js', 'utf8');
- const windowObject = {};
+ const windowObject = {
+ SidepanelAccountPoolUi: createAccountPoolUiStub(),
+ };
const localStorageMock = {
getItem() {
return null;
diff --git a/项目完整链路说明.md b/项目完整链路说明.md
index 9062194..08da8df 100644
--- a/项目完整链路说明.md
+++ b/项目完整链路说明.md
@@ -28,7 +28,7 @@
### 2.1 Sidepanel
-[sidepanel/sidepanel.html](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/sidepanel.html) + [sidepanel/sidepanel.js](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/sidepanel.js) + [sidepanel/update-service.js](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/update-service.js) + [sidepanel/contribution-content-update-service.js](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/contribution-content-update-service.js)
+[sidepanel/sidepanel.html](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/sidepanel.html) + [sidepanel/sidepanel.js](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/sidepanel.js) + [sidepanel/account-pool-ui.js](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/account-pool-ui.js) + [sidepanel/update-service.js](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/update-service.js) + [sidepanel/contribution-content-update-service.js](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/contribution-content-update-service.js)
职责:
@@ -43,6 +43,7 @@
- 在 sidepanel 初始化和点击“自动”按钮前刷新一次贡献站公开内容摘要;如果刷新失败,不阻塞主自动流程
- 在日志区通过“记录”按钮打开独立的邮箱记录覆盖层,并展示成功/失败/停止/重试统计与分页列表
- 查询 GitHub Releases 并展示更新卡片;当前更新服务会区分 `Pro` 与 legacy `v` 两个版本族,排序时优先保持版本族语义一致,同时会在读取缓存后重新排序,避免旧缓存把 `v` 版本误显示为比 `Pro` 更新
+- 为 Hotmail / 2925 账号池复用同一套“添加账号 / 取消添加 / 批量导入 / 收起列表”表单交互;共享的显隐控制放在 `sidepanel/account-pool-ui.js`,各自 manager 只保留 provider 相关字段校验与业务操作
### 2.2 Background Service Worker
@@ -330,7 +331,7 @@
补充行为:
- `2925` provider 会关闭 Step 4 / 8 的自动重发间隔 25 秒节流;每次“重新发送验证码”之间,会在邮箱页内部执行一轮固定 15 次的刷新轮询,不再因 OAuth 剩余时间预算而缩短。
-- 当 provider 为 `2925` 时,后台会在进入 Step 4 / Step 8 之前先确保当前 2925 账号已登录网页邮箱;如果当前标签页仍未登录,会自动补一次“清理 2925 cookie -> 等待 3 秒 -> 打开登录页 -> 页面打开后再等待 3 秒 -> 使用当前账号密码重新登录”。
+- 当 provider 为 `2925` 时,Step 4 会优先直接打开当前 2925 邮箱页:如果页面仍停留在收件箱,就直接复用当前已登录页面;如果页面已经跳到登录页,则只有在启用了 2925 账号池时才会自动登录,未启用账号池时会直接报错停止。Step 8 不再额外承接这套登录态处理。
- 普通邮箱仍会携带 `filterAfterTimestamp` 做时间窗筛选;`2925` 当前既不依赖时间窗,也不再做“新旧邮件快照差集”比较,而是每次刷新后直接遍历当前列表中的匹配邮件。
- 自动重新发送验证码次数现在使用 sidepanel 里的单一“验证码重发”配置;普通邮箱仍按 25 秒间隔节流,Hotmail / 2925 不走这个 25 秒间隔。Step 4 若启用先请求新验证码,会先消耗一次当前步骤的自动重发次数。
- 验证码提交重试上限当前为 15 次;页面明确拒绝验证码时,会在上限内继续拉取新验证码并重提。
@@ -509,6 +510,8 @@
- [hotmail-utils.js](c:/Users/projectf/Downloads/codex注册扩展/hotmail-utils.js)
- [microsoft-email.js](c:/Users/projectf/Downloads/codex注册扩展/microsoft-email.js)
- [scripts/hotmail_helper.py](c:/Users/projectf/Downloads/codex注册扩展/scripts/hotmail_helper.py)
+- [sidepanel/hotmail-manager.js](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/hotmail-manager.js)
+- [sidepanel/account-pool-ui.js](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/account-pool-ui.js)
模式:
@@ -519,6 +522,7 @@
- 本地 helper 除了收信与验证码读取,还提供邮箱记录 JSON 快照同步接口。
- 账号运行历史本地同步由独立配置控制,不再绑定 Hotmail 的本地助手模式。
+- sidepanel 中 Hotmail 账号池的新增表单默认收起,头部通过共享按钮切换“添加账号 / 取消添加”;表单显隐、按钮文案切换、清空与聚焦都复用 `sidepanel/account-pool-ui.js`,不在 Hotmail manager 内重复实现一套。
### 7.3 LuckMail
@@ -534,6 +538,7 @@
- [mail2925-utils.js](c:/Users/projectf/Downloads/codex注册扩展/mail2925-utils.js)
- [background/mail-2925-session.js](c:/Users/projectf/Downloads/codex注册扩展/background/mail-2925-session.js)
- [sidepanel/mail-2925-manager.js](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/mail-2925-manager.js)
+- [sidepanel/account-pool-ui.js](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/account-pool-ui.js)
职责:
@@ -543,15 +548,18 @@
统一承接 2925 账号池的持久化、当前账号切换、cookie 清理登出、网页登录态确认、自动登录,以及命中“子邮箱已达上限邮箱”后的 24 小时禁用与自动切号。
- `sidepanel/mail-2925-manager.js`
负责 2925 账号池的新增、导入、切换、手动登录、禁用、清冷却与删除。
+- `sidepanel/account-pool-ui.js`
+ 负责 Hotmail / 2925 账号池共用的新增表单显隐、头部按钮文案切换、清空表单与首字段聚焦;2925 manager 不再单独维护另一套表单开关状态机。
链路:
1. 用户在 sidepanel 的 2925 账号池中保存 `email / password`
2. 只有当 sidepanel 中的 `mail2925UseAccountPool` 开关开启时,别名基邮箱才会优先取当前账号池选中的 2925 账号邮箱;关闭时会回退到原来的手填 `mail2925BaseEmail`
-3. 手动点击 `登录` 或自动流程进入 Step 4 / Step 8 前,只有在号池模式开启时,后台才会确保当前 2925 账号已登录网页邮箱;登录动作提交后,内容脚本会在 20 秒内轮询“是否已进入收件箱”
+3. 手动点击 `登录` 或自动流程进入 Step 4 前,后台会先打开当前 2925 邮箱页:如果仍停留在收件箱则直接复用;如果跳到登录页,则仅在号池模式开启时才自动登录,关闭号池时直接停止
4. 一旦轮询期间出现“子邮箱已达上限邮箱”,后台会先记录当前时间;若当前处于号池模式且还有下一个可用账号,则把当前账号禁用 24 小时并自动切到下一个账号重新登录
5. 如果登录提交后 20 秒内仍未进入收件箱,且当前正处于自动运行中,则后台会直接复用现有 `requestStop()` 停止链路,把整个自动流程停成和用户手动点击“停止”一致的状态;这类情况常见于图片验证、行为验证或其他阻断登录的中间页
6. 如果没有下一个可用账号,或当前未启用号池模式,则不会继续消耗自动重试次数,而是直接复用现有 `requestStop()` 停止链路,把整个自动流程停成和用户手动点击“停止”一致的状态
+7. sidepanel 中 2925 账号池的新增表单也走与 Hotmail 相同的共享交互:默认收起,头部按钮切换“添加账号 / 取消添加”,操作行右侧提供“批量导入”,保存成功后自动收起并清空。
### 7.5 iCloud
diff --git a/项目开发规范(AI协作).md b/项目开发规范(AI协作).md
index 13dcd4c..5b99d0e 100644
--- a/项目开发规范(AI协作).md
+++ b/项目开发规范(AI协作).md
@@ -96,6 +96,7 @@
- `background/generated-email-helpers.js` 只负责调度,不应再次复制 Gmail / 2925 规则
- `background/signup-flow-helpers.js` 只负责“复用已有邮箱还是重新生成”的流程决策
- `sidepanel/sidepanel.js` 只负责 UI 接线、校验触发和状态同步
+- Hotmail / 2925 账号池这类跨 provider 的 sidepanel 表单显隐、头部按钮文案切换与共用操作行,应优先收敛到共享 UI helper(当前为 `sidepanel/account-pool-ui.js`),不要在各自 manager 中复制一套近似状态机
### 3.3 新增配置项
diff --git a/项目文件结构说明.md b/项目文件结构说明.md
index 2d7eb8c..2de56ea 100644
--- a/项目文件结构说明.md
+++ b/项目文件结构说明.md
@@ -112,6 +112,7 @@
## `sidepanel/`
+- `sidepanel/account-pool-ui.js`:侧边栏号池表单共享 UI helper,负责 Hotmail / 2925 账号池新增表单的显隐、头部“添加账号/取消添加”按钮文案切换、清空表单与首字段聚焦。
- `sidepanel/hotmail-manager.js`:侧边栏 Hotmail 账号池管理器,负责列表、导入、验证、测试收信和批量操作。
- `sidepanel/icloud-manager.js`:侧边栏 iCloud 隐私邮箱管理器,负责列表、筛选、保留、删除和批量操作。
- `sidepanel/luckmail-manager.js`:侧边栏 LuckMail 管理器,负责邮箱列表、筛选、启停、保留与批量操作。
@@ -119,9 +120,9 @@
- `sidepanel/account-records-manager.js`:侧边栏邮箱记录面板管理器,负责“记录”按钮、覆盖层开关、分页列表、成功/失败/停止统计摘要和清理确认。
- `sidepanel/contribution-content-update-service.js`:侧边栏贡献内容更新服务,负责拉取 `https://apikey.qzz.io/api/content-summary`、缓存公开公告/教程摘要,并输出可用于提示展示的 `promptVersion` 与最新更新时间。
- `sidepanel/contribution-mode.js`:侧边栏贡献模式管理器,负责顶部“贡献”按钮、确认弹窗、贡献模式显隐、复用主自动流程启动、侧栏内贡献状态轮询、上传页跳转,以及贡献模式下对来源选择、配置入口、记录入口和敏感配置行的禁用与隐藏。
-- `sidepanel/sidepanel.css`:侧边栏样式文件。
-- `sidepanel/sidepanel.html`:侧边栏页面结构;当前步骤列表已改为动态容器,日志区提供“记录”按钮并挂接邮箱记录覆盖层,顶部新增“贡献”按钮并在设置卡片中新增贡献模式主面板;贡献按钮下方额外挂接一个可关闭的轻提示气泡,用于提示公开公告 / 使用教程有更新;贡献面板内展示 `OAUTH / 回调 / 总状态` 三块真实运行态信息,同时把“本地同步”与“验证码重发”拆成独立行以避免特殊模式隐藏时互相影响;页面继续加载 `managed-alias-utils.js`,并把旧的“邮箱前缀”字段语义改为“别名基邮箱”;当 provider 为 2925 时,别名基邮箱行右侧会显示“号池”开关,开启后输入框切换为号池邮箱下拉选择,并显示 2925 账号池管理区。
-- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、独立本地同步配置、共享验证码自动重发次数配置与广播接收,并装配 Hotmail / 2925 / iCloud / LuckMail / 贡献模式 / 邮箱记录面板 / 贡献内容更新服务;当前贡献模式的“开始贡献”会直接复用主自动流程启动逻辑,而独立 manager 负责贡献运行态展示与轮询,同时把 Gmail / 2925 的基邮箱输入、完整注册邮箱输入、自动生成按钮与兼容性校验统一接到共享别名逻辑上;当 2925 号池开关开启时,当前账号变化会同步映射到 2925 基邮箱下拉选择,关闭时则回退到原来的手填基邮箱路线;侧边栏初始化与点击“自动”前会刷新一次贡献站公开内容摘要,并按本地关闭版本决定是否展示轻提示。
+- `sidepanel/sidepanel.css`:侧边栏样式文件;当前额外提供 Hotmail / 2925 共用的号池表单容器、操作按钮行与统一的收起态列表高度样式。
+- `sidepanel/sidepanel.html`:侧边栏页面结构;当前步骤列表已改为动态容器,日志区提供“记录”按钮并挂接邮箱记录覆盖层,顶部新增“贡献”按钮并在设置卡片中新增贡献模式主面板;贡献按钮下方额外挂接一个可关闭的轻提示气泡,用于提示公开公告 / 使用教程有更新;贡献面板内展示 `OAUTH / 回调 / 总状态` 三块真实运行态信息,同时把“本地同步”与“验证码重发”拆成独立行以避免特殊模式隐藏时互相影响;页面继续加载 `managed-alias-utils.js`,并把旧的“邮箱前缀”字段语义改为“别名基邮箱”;当 provider 为 2925 时,别名基邮箱行右侧会显示“号池”开关,开启后输入框切换为号池邮箱下拉选择,并显示 2925 账号池管理区;Hotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器。
+- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、独立本地同步配置、共享验证码自动重发次数配置与广播接收,并装配 Hotmail / 2925 / iCloud / LuckMail / 贡献模式 / 邮箱记录面板 / 贡献内容更新服务;当前贡献模式的“开始贡献”会直接复用主自动流程启动逻辑,而独立 manager 负责贡献运行态展示与轮询,同时把 Gmail / 2925 的基邮箱输入、完整注册邮箱输入、自动生成按钮与兼容性校验统一接到共享别名逻辑上;当 2925 号池开关开启时,当前账号变化会同步映射到 2925 基邮箱下拉选择,关闭时则回退到原来的手填基邮箱路线;Hotmail / 2925 的新增表单显隐则统一接到 `sidepanel/account-pool-ui.js` 这一层共享 helper;侧边栏初始化与点击“自动”前会刷新一次贡献站公开内容摘要,并按本地关闭版本决定是否展示轻提示。
- `sidepanel/update-service.js`:侧边栏更新检查服务,负责 GitHub Releases 查询、`Pro` / `v` 双版本族排序、缓存读取与版本展示。
## `tests/`
@@ -171,7 +172,7 @@
- `tests/mail2925-utils.test.js`:测试 2925 账号池工具层的账号归一化、冷却期判断、可用账号挑选与批量导入解析。
- `tests/managed-alias-utils.test.js`:覆盖 Gmail / 2925 共享别名工具的解析、生成、兼容性判断。
- `tests/microsoft-email.test.js`:测试 Microsoft 邮件拉取与验证码提取逻辑。
-- `tests/sidepanel-hotmail-manager.test.js`:测试侧边栏 Hotmail 管理器模块接线与空态渲染。
+- `tests/sidepanel-hotmail-manager.test.js`:测试侧边栏 Hotmail 管理器模块接线、共享号池表单显隐交互与空态渲染。
- `tests/sidepanel-contribution-button.test.js`:测试侧边栏顶部 `贡献` 按钮的 HTML 接线、更新提示气泡接线,以及相关脚本加载顺序。
- `tests/sidepanel-account-records-manager.test.js`:测试侧边栏邮箱记录覆盖层的 HTML 接入、helper 地址归一化与 manager 渲染逻辑。
- `tests/contribution-content-update-service.test.js`:测试贡献内容更新服务对公开摘要接口的归一化、版本提取与失败回退缓存逻辑。
@@ -180,7 +181,7 @@
- `tests/sidepanel-icloud-manager.test.js`:测试侧边栏 iCloud 管理器模块接线与空态渲染。
- `tests/sidepanel-icloud-provider.test.js`:测试侧边栏 iCloud 登录地址解析逻辑。
- `tests/sidepanel-luckmail-manager.test.js`:测试侧边栏 LuckMail 管理器模块接线与空态渲染。
-- `tests/sidepanel-mail2925-manager.test.js`:测试侧边栏 2925 管理器模块接线、脚本加载顺序与空态渲染。
+- `tests/sidepanel-mail2925-manager.test.js`:测试侧边栏 2925 管理器模块接线、共享号池表单脚本加载顺序、显隐交互与空态渲染。
- `tests/signup-entry-diagnostics.test.js`:测试注册入口诊断快照输出。
- `tests/signup-page-tab-cleanup.test.js`:测试注册页来源标签的冲突清理逻辑。
- `tests/step-definitions-module.test.js`:测试共享步骤定义模块及侧边栏脚本加载顺序。
From 95289c37b8bccc88e685d91086f82ddc4f2b0ef2 Mon Sep 17 00:00:00 2001
From: QLHazyCoder <2825305047@qq.com>
Date: Tue, 21 Apr 2026 19:00:16 +0800
Subject: [PATCH 03/23] =?UTF-8?q?feat:=20=E5=90=8C=E6=AD=A52925=E5=8F=B7?=
=?UTF-8?q?=E6=B1=A0=E5=BD=93=E5=89=8D=E9=80=89=E4=B8=AD=E9=82=AE=E7=AE=B1?=
=?UTF-8?q?=E5=88=B0mail2925BaseEmail=E5=AD=97=E6=AE=B5=EF=BC=8C=E4=BC=98?=
=?UTF-8?q?=E5=8C=96=E7=94=A8=E6=88=B7=E4=BD=93=E9=AA=8C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
background/mail-2925-session.js | 4 +-
sidepanel/sidepanel.js | 24 +++-
tests/sidepanel-mail2925-base-email.test.js | 144 ++++++++++++++++++++
项目完整链路说明.md | 1 +
4 files changed, 170 insertions(+), 3 deletions(-)
create mode 100644 tests/sidepanel-mail2925-base-email.test.js
diff --git a/background/mail-2925-session.js b/background/mail-2925-session.js
index 5d73f83..2c9f900 100644
--- a/background/mail-2925-session.js
+++ b/background/mail-2925-session.js
@@ -446,7 +446,7 @@
const targetUrl = forceRelogin ? MAIL2925_LOGIN_URL : MAIL2925_URL;
await addLog(
forceRelogin
- ? `2925:准备打开登录页 ${MAIL2925_LOGIN_URL}(forceRelogin=true)`
+ ? `2925:准备打开登录页 ${MAIL2925_LOGIN_URL}(强制重登录)`
: `2925:准备打开邮箱页 ${MAIL2925_URL}(登录页自动登录=${allowLoginWhenOnLoginPage ? '开启' : '关闭'})`,
'info'
);
@@ -467,7 +467,7 @@
(url) => isMail2925LoginUrl(url),
{ timeoutMs: 15000, retryDelayMs: 300 }
);
- await addLog(`2925:等待最终落到登录页结果:${matchedLoginTab?.url || 'timeout'}`, matchedLoginTab ? 'info' : 'warn');
+ await addLog(`2925:等待最终落到登录页结果:${matchedLoginTab?.url || '超时'}`, matchedLoginTab ? 'info' : 'warn');
if (matchedLoginTab?.url) {
openedUrl = String(matchedLoginTab.url || '').trim();
}
diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js
index f99ba4c..a11c81d 100644
--- a/sidepanel/sidepanel.js
+++ b/sidepanel/sidepanel.js
@@ -376,7 +376,10 @@ async function syncSelectedMail2925PoolAccount(options = {}) {
throw new Error(response.error);
}
- syncLatestState({ currentMail2925AccountId: response.account?.id || accountId });
+ syncLatestState({
+ currentMail2925AccountId: response.account?.id || accountId,
+ ...(response.account?.email ? { mail2925BaseEmail: String(response.account.email).trim() } : {}),
+ });
setManagedAliasBaseEmailInputForProvider('2925', latestState);
if (!silent) {
showToast(`已切换当前 2925 号池邮箱为 ${response.account?.email || accountId}`, 'success', 1800);
@@ -2430,6 +2433,24 @@ function getCurrentMail2925Email(state = latestState) {
return String(getCurrentMail2925Account(state)?.email || '').trim();
}
+function syncMail2925BaseEmailFromCurrentAccount(state = latestState, options = {}) {
+ const { persist = false } = options;
+ if (!isMail2925AccountPoolEnabled(state)) {
+ return false;
+ }
+
+ const currentEmail = getCurrentMail2925Email(state);
+ if (!currentEmail || currentEmail === String(state?.mail2925BaseEmail || '').trim()) {
+ return false;
+ }
+
+ syncLatestState({ mail2925BaseEmail: currentEmail });
+ if (persist) {
+ saveSettings({ silent: true }).catch(() => {});
+ }
+ return true;
+}
+
function getCurrentLuckmailPurchase(state = latestState) {
return state?.currentLuckmailPurchase || null;
}
@@ -3158,6 +3179,7 @@ const mail2925Manager = window.SidepanelMail2925Manager?.createMail2925Manager({
getMail2925Accounts,
openConfirmModal,
refreshManagedAliasBaseEmail: () => {
+ syncMail2925BaseEmailFromCurrentAccount(latestState, { persist: true });
setManagedAliasBaseEmailInputForProvider('2925', latestState);
},
showToast,
diff --git a/tests/sidepanel-mail2925-base-email.test.js b/tests/sidepanel-mail2925-base-email.test.js
new file mode 100644
index 0000000..340534d
--- /dev/null
+++ b/tests/sidepanel-mail2925-base-email.test.js
@@ -0,0 +1,144 @@
+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 extractFunction(name) {
+ const markers = [`async function ${name}(`, `function ${name}(`];
+ const start = markers
+ .map((marker) => sidepanelSource.indexOf(marker))
+ .find((index) => index >= 0);
+ if (start < 0) {
+ throw new Error(`missing function ${name}`);
+ }
+
+ let parenDepth = 0;
+ let signatureEnded = false;
+ let braceStart = -1;
+ for (let i = start; i < sidepanelSource.length; i += 1) {
+ const ch = sidepanelSource[i];
+ if (ch === '(') {
+ parenDepth += 1;
+ } else if (ch === ')') {
+ parenDepth -= 1;
+ if (parenDepth === 0) {
+ signatureEnded = true;
+ }
+ } else if (ch === '{' && signatureEnded) {
+ braceStart = i;
+ break;
+ }
+ }
+
+ let depth = 0;
+ let end = braceStart;
+ for (; end < sidepanelSource.length; end += 1) {
+ const ch = sidepanelSource[end];
+ if (ch === '{') depth += 1;
+ if (ch === '}') {
+ depth -= 1;
+ if (depth === 0) {
+ end += 1;
+ break;
+ }
+ }
+ }
+
+ return sidepanelSource.slice(start, end);
+}
+
+test('syncSelectedMail2925PoolAccount writes selected pool email back to mail2925BaseEmail', async () => {
+ const bundle = [
+ extractFunction('getMail2925Accounts'),
+ extractFunction('getCurrentMail2925Account'),
+ extractFunction('getCurrentMail2925Email'),
+ extractFunction('isMail2925AccountPoolEnabled'),
+ extractFunction('syncMail2925PoolAccountOptions'),
+ extractFunction('getPreferredMail2925PoolAccountId'),
+ extractFunction('syncSelectedMail2925PoolAccount'),
+ ].join('\n');
+
+ const api = new Function(`
+let latestState = {
+ mail2925UseAccountPool: true,
+ mail2925BaseEmail: 'old@2925.com',
+ currentMail2925AccountId: '',
+ mail2925Accounts: [{ id: 'acc-1', email: 'new@2925.com' }],
+};
+const selectMail2925PoolAccount = { value: 'acc-1', innerHTML: '' };
+const chrome = {
+ runtime: {
+ async sendMessage() {
+ return { account: { id: 'acc-1', email: 'new@2925.com' } };
+ },
+ },
+};
+const toastEvents = [];
+function syncLatestState(patch) {
+ latestState = { ...latestState, ...patch };
+}
+function setManagedAliasBaseEmailInputForProvider() {}
+function showToast(message) {
+ toastEvents.push(message);
+}
+function escapeHtml(value) {
+ return String(value || '');
+}
+${bundle}
+return {
+ syncSelectedMail2925PoolAccount,
+ getLatestState() {
+ return latestState;
+ },
+};
+`)();
+
+ await api.syncSelectedMail2925PoolAccount({ silent: true });
+
+ assert.equal(api.getLatestState().currentMail2925AccountId, 'acc-1');
+ assert.equal(api.getLatestState().mail2925BaseEmail, 'new@2925.com');
+});
+
+test('syncMail2925BaseEmailFromCurrentAccount reuses current pool account email for manual base email field', async () => {
+ const bundle = [
+ extractFunction('getMail2925Accounts'),
+ extractFunction('getCurrentMail2925Account'),
+ extractFunction('getCurrentMail2925Email'),
+ extractFunction('isMail2925AccountPoolEnabled'),
+ extractFunction('syncMail2925BaseEmailFromCurrentAccount'),
+ ].join('\n');
+
+ const api = new Function(`
+let latestState = {
+ mail2925UseAccountPool: true,
+ mail2925BaseEmail: 'old@2925.com',
+ currentMail2925AccountId: 'acc-1',
+ mail2925Accounts: [{ id: 'acc-1', email: 'new@2925.com' }],
+};
+let saveCalls = 0;
+function syncLatestState(patch) {
+ latestState = { ...latestState, ...patch };
+}
+async function saveSettings() {
+ saveCalls += 1;
+}
+${bundle}
+return {
+ syncMail2925BaseEmailFromCurrentAccount,
+ getLatestState() {
+ return latestState;
+ },
+ getSaveCalls() {
+ return saveCalls;
+ },
+};
+`)();
+
+ const changed = api.syncMail2925BaseEmailFromCurrentAccount(undefined, { persist: true });
+ await Promise.resolve();
+
+ assert.equal(changed, true);
+ assert.equal(api.getLatestState().mail2925BaseEmail, 'new@2925.com');
+ assert.equal(api.getSaveCalls(), 1);
+});
diff --git a/项目完整链路说明.md b/项目完整链路说明.md
index 08da8df..4567a48 100644
--- a/项目完整链路说明.md
+++ b/项目完整链路说明.md
@@ -560,6 +560,7 @@
5. 如果登录提交后 20 秒内仍未进入收件箱,且当前正处于自动运行中,则后台会直接复用现有 `requestStop()` 停止链路,把整个自动流程停成和用户手动点击“停止”一致的状态;这类情况常见于图片验证、行为验证或其他阻断登录的中间页
6. 如果没有下一个可用账号,或当前未启用号池模式,则不会继续消耗自动重试次数,而是直接复用现有 `requestStop()` 停止链路,把整个自动流程停成和用户手动点击“停止”一致的状态
7. sidepanel 中 2925 账号池的新增表单也走与 Hotmail 相同的共享交互:默认收起,头部按钮切换“添加账号 / 取消添加”,操作行右侧提供“批量导入”,保存成功后自动收起并清空。
+8. 当 2925 号池模式开启时,当前选中的号池邮箱会同步回写到同一个 `mail2925BaseEmail` 字段;因此用户切换号池账号后,即使再次关闭号池模式,也会直接沿用刚才选中的邮箱作为手动基邮箱,无需重新输入。
### 7.5 iCloud
From 4781b05e5e59521f1ea825ac9ee2607b736704c0 Mon Sep 17 00:00:00 2001
From: QLHazyCoder <2825305047@qq.com>
Date: Tue, 21 Apr 2026 19:26:49 +0800
Subject: [PATCH 04/23] chore(release): bump version to Pro5.3
---
manifest.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/manifest.json b/manifest.json
index 791cbb0..dd4c5b7 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "多页面自动化",
- "version": "5.0",
- "version_name": "Pro5.0",
+ "version": "5.3",
+ "version_name": "Pro5.3",
"description": "用于自动执行多步骤 OAuth 注册流程",
"permissions": [
"sidePanel",
From da0ca04153897ea2df77e9163a3be37448feacbc Mon Sep 17 00:00:00 2001
From: dyhhhhhqwer <1613203335@qq.com>
Date: Tue, 21 Apr 2026 19:46:28 +0800
Subject: [PATCH 05/23] Fix Cloudflare signup verification recovery
---
background/steps/fetch-signup-code.js | 7 +-
content/signup-page.js | 129 +++++++++++++++--
tests/background-step4-filter-window.test.js | 50 +++++++
tests/step4-split-code-submit.test.js | 141 +++++++++++++++++++
tests/step4-submit-retry-recovery.test.js | 103 ++++++++++++++
5 files changed, 419 insertions(+), 11 deletions(-)
create mode 100644 tests/step4-split-code-submit.test.js
create mode 100644 tests/step4-submit-retry-recovery.test.js
diff --git a/background/steps/fetch-signup-code.js b/background/steps/fetch-signup-code.js
index e85b250..294e458 100644
--- a/background/steps/fetch-signup-code.js
+++ b/background/steps/fetch-signup-code.js
@@ -105,11 +105,16 @@
}
}
+ const shouldRequestFreshCodeFirst = ![
+ HOTMAIL_PROVIDER,
+ CLOUDFLARE_TEMP_EMAIL_PROVIDER,
+ ].includes(mail.provider);
+
await resolveVerificationStep(4, state, mail, {
filterAfterTimestamp: verificationFilterAfterTimestamp,
sessionKey: verificationSessionKey,
disableTimeBudgetCap: mail.provider === '2925',
- requestFreshCodeFirst: mail.provider === HOTMAIL_PROVIDER ? false : true,
+ requestFreshCodeFirst: shouldRequestFreshCodeFirst,
resendIntervalMs: (mail.provider === HOTMAIL_PROVIDER || mail.provider === '2925')
? 0
: STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS,
diff --git a/content/signup-page.js b/content/signup-page.js
index a6d44d8..9b7548d 100644
--- a/content/signup-page.js
+++ b/content/signup-page.js
@@ -1622,15 +1622,27 @@ async function prepareSignupVerificationFlow(payload = {}, timeout = 30000) {
async function waitForVerificationSubmitOutcome(step, timeout) {
const resolvedTimeout = timeout ?? (step === 8 ? 30000 : 12000);
const start = Date.now();
+ let recoveryCount = 0;
+ const maxRecoveryCount = 2;
while (Date.now() - start < resolvedTimeout) {
throwIfStopped();
- if (step === 4) {
- const signupRetryState = getCurrentAuthRetryPageState('signup');
- if (signupRetryState?.userAlreadyExistsBlocked) {
- throw createSignupUserAlreadyExistsError();
- }
+ const retryFlow = step === 4 ? 'signup' : 'login';
+ const retryState = getCurrentAuthRetryPageState(retryFlow);
+ if (retryState?.userAlreadyExistsBlocked) {
+ throw createSignupUserAlreadyExistsError();
+ }
+ if (retryState && recoveryCount < maxRecoveryCount) {
+ recoveryCount += 1;
+ log(`步骤 ${step}:验证码提交后进入认证重试页,正在自动恢复(${recoveryCount}/${maxRecoveryCount})...`, 'warn');
+ await recoverCurrentAuthRetryPage({
+ flow: retryFlow,
+ logLabel: `步骤 ${step}:验证码提交后检测到认证重试页,正在点击“重试”恢复`,
+ step,
+ timeoutMs: 12000,
+ });
+ continue;
}
const errorText = getVerificationErrorText();
@@ -1670,6 +1682,76 @@ async function waitForVerificationSubmitOutcome(step, timeout) {
return { success: true, assumed: true };
}
+function getVerificationSubmitButtonForTarget(codeInput, options = {}) {
+ const { allowDisabled = false } = options;
+ const form = codeInput?.form || codeInput?.closest?.('form') || null;
+ const isUsableAction = (element) => {
+ if (!element || !isVisibleElement(element)) return false;
+ return allowDisabled || isActionEnabled(element);
+ };
+
+ const findSubmitInRoot = (root) => {
+ if (!root?.querySelectorAll) return null;
+
+ const directCandidates = root.querySelectorAll('button[type="submit"], input[type="submit"]');
+ for (const element of directCandidates) {
+ if (isUsableAction(element)) {
+ return element;
+ }
+ }
+
+ const textCandidates = root.querySelectorAll('button, [role="button"], input[type="button"], input[type="submit"]');
+ return Array.from(textCandidates).find((element) => {
+ if (!isUsableAction(element)) return false;
+ const text = getActionText(element);
+ return /verify|confirm|submit|continue|确认|验证|继续/i.test(text);
+ }) || null;
+ };
+
+ return findSubmitInRoot(form) || findSubmitInRoot(document);
+}
+
+async function waitForVerificationSubmitButton(codeInput, timeout = 5000) {
+ const start = Date.now();
+
+ while (Date.now() - start < timeout) {
+ throwIfStopped();
+ if (is405MethodNotAllowedPage()) {
+ throw new Error('当前页面处于 405 错误恢复流程中,暂时无法定位验证码提交按钮。');
+ }
+
+ const button = getVerificationSubmitButtonForTarget(codeInput, { allowDisabled: false });
+ if (button) {
+ return button;
+ }
+
+ await sleep(150);
+ }
+
+ return null;
+}
+
+async function waitForSplitVerificationInputsFilled(inputs, code, timeout = 2500) {
+ const expected = String(code || '').slice(0, 6);
+ const start = Date.now();
+
+ while (Date.now() - start < timeout) {
+ throwIfStopped();
+ const current = Array.from(inputs || [])
+ .slice(0, expected.length)
+ .map((input) => String(input?.value || '').trim())
+ .join('');
+
+ if (current === expected) {
+ return true;
+ }
+
+ await sleep(100);
+ }
+
+ return false;
+}
+
async function fillVerificationCode(step, payload) {
const { code } = payload;
if (!code) throw new Error('未提供验证码。');
@@ -1704,9 +1786,37 @@ async function fillVerificationCode(step, payload) {
if (singleInputs.length >= 6) {
log(`步骤 ${step}:发现分开的单字符验证码输入框,正在逐个填写...`);
for (let i = 0; i < 6 && i < singleInputs.length; i++) {
+ const targetInput = singleInputs[i];
+ try {
+ targetInput.focus?.();
+ } catch {}
fillInput(singleInputs[i], code[i]);
+ try {
+ targetInput.dispatchEvent(new KeyboardEvent('keyup', { key: code[i], bubbles: true }));
+ } catch {}
await sleep(100);
}
+ const filled = await waitForSplitVerificationInputsFilled(singleInputs, code, 2500);
+ if (!filled) {
+ const current = Array.from(singleInputs)
+ .slice(0, 6)
+ .map((input) => String(input?.value || '').trim() || '_')
+ .join('');
+ log(`步骤 ${step}:分格验证码输入框未稳定呈现目标值,当前页面值为 ${current},准备继续观察提交流程。`, 'warn');
+ } else {
+ log(`步骤 ${step}:分格验证码输入框已稳定显示 ${code}。`, 'info');
+ }
+
+ await sleep(800);
+ const splitSubmitBtn = await waitForVerificationSubmitButton(singleInputs[0], 2000).catch(() => null);
+ if (splitSubmitBtn) {
+ await humanPause(450, 1200);
+ simulateClick(splitSubmitBtn);
+ log(`步骤 ${step}:分格验证码已提交`);
+ } else {
+ log(`步骤 ${step}:分格验证码页面未找到可点击提交按钮,继续等待页面自动推进。`, 'info');
+ }
+
const outcome = await waitForVerificationSubmitOutcome(step);
if (outcome.invalidCode) {
log(`步骤 ${step}:验证码被拒绝:${outcome.errorText}`, 'warn');
@@ -1736,17 +1846,16 @@ async function fillVerificationCode(step, payload) {
fillInput(codeInput, code);
log(`步骤 ${step}:验证码已填写`);
- // Report complete BEFORE submit (page may navigate away)
-
// Submit
- await sleep(500);
- const submitBtn = document.querySelector('button[type="submit"]')
- || await waitForElementByText('button', /verify|confirm|submit|continue|确认|验证/i, 5000).catch(() => null);
+ await sleep(800);
+ const submitBtn = await waitForVerificationSubmitButton(codeInput, 5000).catch(() => null);
if (submitBtn) {
await humanPause(450, 1200);
simulateClick(submitBtn);
log(`步骤 ${step}:验证码已提交`);
+ } else {
+ log(`步骤 ${step}:未找到可提交的验证码按钮,先等待页面自动推进或反馈结果。`, 'warn');
}
const outcome = await waitForVerificationSubmitOutcome(step);
diff --git a/tests/background-step4-filter-window.test.js b/tests/background-step4-filter-window.test.js
index cced4bc..f9c77a9 100644
--- a/tests/background-step4-filter-window.test.js
+++ b/tests/background-step4-filter-window.test.js
@@ -55,3 +55,53 @@ test('step 4 passes a fixed 10-minute lookback window to 2925 mailbox polling',
assert.equal(capturedOptions.filterAfterTimestamp, 100000);
assert.equal(capturedOptions.resendIntervalMs, 0);
});
+
+test('step 4 does not request a fresh code first for Cloudflare temp mail', async () => {
+ let capturedOptions = null;
+ const realDateNow = Date.now;
+ Date.now = () => 700000;
+
+ const executor = api.createStep4Executor({
+ addLog: async () => {},
+ chrome: {
+ tabs: {
+ update: async () => {},
+ },
+ },
+ completeStepFromBackground: async () => {},
+ confirmCustomVerificationStepBypass: async () => {},
+ ensureMail2925MailboxSession: async () => {},
+ getMailConfig: () => ({
+ provider: 'cloudflare-temp-email',
+ label: 'Cloudflare Temp Email',
+ source: 'cloudflare-temp-email',
+ url: 'https://temp.peekcart.com',
+ }),
+ getTabId: async () => 1,
+ HOTMAIL_PROVIDER: 'hotmail-api',
+ isTabAlive: async () => true,
+ LUCKMAIL_PROVIDER: 'luckmail-api',
+ CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
+ resolveVerificationStep: async (_step, _state, _mail, options) => {
+ capturedOptions = options;
+ },
+ reuseOrCreateTab: async () => {},
+ sendToContentScriptResilient: async () => ({}),
+ shouldUseCustomRegistrationEmail: () => false,
+ STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
+ throwIfStopped: () => {},
+ });
+
+ try {
+ await executor.executeStep4({
+ email: 'user@example.com',
+ password: 'secret',
+ });
+ } finally {
+ Date.now = realDateNow;
+ }
+
+ assert.equal(capturedOptions.filterAfterTimestamp, 700000);
+ assert.equal(capturedOptions.requestFreshCodeFirst, false);
+ assert.equal(capturedOptions.resendIntervalMs, 25000);
+});
diff --git a/tests/step4-split-code-submit.test.js b/tests/step4-split-code-submit.test.js
new file mode 100644
index 0000000..6f6010a
--- /dev/null
+++ b/tests/step4-split-code-submit.test.js
@@ -0,0 +1,141 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+const source = fs.readFileSync('content/signup-page.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) {
+ throw new Error(`missing function ${name}`);
+ }
+
+ let parenDepth = 0;
+ let signatureEnded = false;
+ let braceStart = -1;
+ for (let i = start; i < source.length; i += 1) {
+ const ch = source[i];
+ if (ch === '(') {
+ parenDepth += 1;
+ } else if (ch === ')') {
+ parenDepth -= 1;
+ if (parenDepth === 0) {
+ signatureEnded = true;
+ }
+ } else if (ch === '{' && signatureEnded) {
+ braceStart = i;
+ break;
+ }
+ }
+
+ if (braceStart < 0) {
+ throw new Error(`missing body for function ${name}`);
+ }
+
+ 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);
+}
+
+test('fillVerificationCode submits after split inputs are stably filled', async () => {
+ const api = new Function(`
+const logs = [];
+const clicks = [];
+const filledValues = [];
+let submitClicked = false;
+
+const submitBtn = {
+ tagName: 'BUTTON',
+ textContent: 'Continue',
+ disabled: false,
+ getAttribute(name) {
+ if (name === 'type') return 'submit';
+ if (name === 'aria-disabled') return 'false';
+ return '';
+ },
+ click() {
+ submitClicked = true;
+ },
+};
+
+const inputs = Array.from({ length: 6 }, () => ({
+ value: '',
+ focus() {},
+ dispatchEvent() {},
+}));
+
+const document = {
+ querySelector(selector) {
+ if (selector === 'button[type="submit"], input[type="submit"]') return submitBtn;
+ return null;
+ },
+ querySelectorAll(selector) {
+ if (selector === 'input[maxlength="1"]') return inputs;
+ if (selector === 'button[type="submit"], input[type="submit"]') return [submitBtn];
+ if (selector === 'button, [role="button"], input[type="button"], input[type="submit"]') return [submitBtn];
+ return [];
+ },
+};
+
+function throwIfStopped() {}
+function log(message, level = 'info') { logs.push({ message, level }); }
+async function waitForLoginVerificationPageReady() {}
+function is405MethodNotAllowedPage() { return false; }
+async function handle405ResendError() {}
+async function waitForElement() { throw new Error('not found'); }
+function fillInput(el, value) {
+ el.value = value;
+ filledValues.push(value);
+}
+async function sleep() {}
+function isVisibleElement() { return true; }
+function isActionEnabled(el) { return Boolean(el) && !el.disabled; }
+function getActionText(el) { return el.textContent || ''; }
+async function humanPause() {}
+function simulateClick(el) { el.click(); clicks.push(el.textContent); }
+async function waitForVerificationSubmitOutcome() { return { success: true }; }
+
+${extractFunction('getVerificationSubmitButtonForTarget')}
+${extractFunction('waitForVerificationSubmitButton')}
+${extractFunction('waitForSplitVerificationInputsFilled')}
+${extractFunction('fillVerificationCode')}
+
+return {
+ run() {
+ return fillVerificationCode(4, { code: '123456' });
+ },
+ snapshot() {
+ return {
+ logs,
+ clicks,
+ filledValues,
+ submitClicked,
+ currentValue: inputs.map((input) => input.value).join(''),
+ };
+ },
+};
+`)();
+
+ const result = await api.run();
+ const snapshot = api.snapshot();
+
+ assert.deepStrictEqual(result, { success: true });
+ assert.equal(snapshot.currentValue, '123456');
+ assert.equal(snapshot.submitClicked, true);
+ assert.deepStrictEqual(snapshot.clicks, ['Continue']);
+});
diff --git a/tests/step4-submit-retry-recovery.test.js b/tests/step4-submit-retry-recovery.test.js
new file mode 100644
index 0000000..a9a7ed0
--- /dev/null
+++ b/tests/step4-submit-retry-recovery.test.js
@@ -0,0 +1,103 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+const source = fs.readFileSync('content/signup-page.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) {
+ throw new Error(`missing function ${name}`);
+ }
+
+ let parenDepth = 0;
+ let signatureEnded = false;
+ let braceStart = -1;
+ for (let i = start; i < source.length; i += 1) {
+ const ch = source[i];
+ if (ch === '(') {
+ parenDepth += 1;
+ } else if (ch === ')') {
+ parenDepth -= 1;
+ if (parenDepth === 0) {
+ signatureEnded = true;
+ }
+ } else if (ch === '{' && signatureEnded) {
+ braceStart = i;
+ break;
+ }
+ }
+ if (braceStart < 0) {
+ throw new Error(`missing body for function ${name}`);
+ }
+
+ 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);
+}
+
+test('waitForVerificationSubmitOutcome recovers signup retry page after submit', async () => {
+ const api = new Function(`
+let retryVisible = true;
+let step5Ready = false;
+let recoverCalls = 0;
+const location = { href: 'https://auth.openai.com/email-verification' };
+
+function throwIfStopped() {}
+function log() {}
+function getVerificationErrorText() { return ''; }
+function isStep5Ready() { return step5Ready; }
+function isStep8Ready() { return false; }
+function isAddPhonePageReady() { return false; }
+function isVerificationPageStillVisible() { return false; }
+function createSignupUserAlreadyExistsError() {
+ return new Error('SIGNUP_USER_ALREADY_EXISTS::步骤 4:检测到 user_already_exists,说明当前用户已存在,当前轮将直接停止。');
+}
+function getCurrentAuthRetryPageState(flow) {
+ if (flow === 'signup' && retryVisible) {
+ return {
+ retryEnabled: true,
+ userAlreadyExistsBlocked: false,
+ };
+ }
+ return null;
+}
+async function recoverCurrentAuthRetryPage() {
+ recoverCalls += 1;
+ retryVisible = false;
+ step5Ready = true;
+}
+async function sleep() {}
+
+${extractFunction('waitForVerificationSubmitOutcome')}
+
+return {
+ run() {
+ return waitForVerificationSubmitOutcome(4, 1000);
+ },
+ snapshot() {
+ return { recoverCalls };
+ },
+};
+`)();
+
+ const result = await api.run();
+
+ assert.deepStrictEqual(result, { success: true });
+ assert.equal(api.snapshot().recoverCalls, 1);
+});
From 1c96098fc564a4b379499709f19642b2fee3f55e Mon Sep 17 00:00:00 2001
From: QLHazyCoder <2825305047@qq.com>
Date: Tue, 21 Apr 2026 21:27:36 +0800
Subject: [PATCH 06/23] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=962925=E9=82=AE?=
=?UTF-8?q?=E7=AE=B1=E7=99=BB=E5=BD=95=E9=80=BB=E8=BE=91=EF=BC=8C=E5=A2=9E?=
=?UTF-8?q?=E5=BC=BA=E8=B4=A6=E5=8F=B7=E6=B1=A0=E7=AE=A1=E7=90=86=E4=B8=8E?=
=?UTF-8?q?=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
background/mail-2925-session.js | 86 +++++++++++--------
sidepanel/sidepanel.js | 8 +-
...background-mail2925-entry-behavior.test.js | 61 +++++++++++++
.../background-mail2925-relogin-wait.test.js | 2 +-
...background-mail2925-session-module.test.js | 69 +++++++++++++++
项目完整链路说明.md | 6 +-
6 files changed, 189 insertions(+), 43 deletions(-)
diff --git a/background/mail-2925-session.js b/background/mail-2925-session.js
index 2c9f900..7e8f2af 100644
--- a/background/mail-2925-session.js
+++ b/background/mail-2925-session.js
@@ -86,10 +86,9 @@
function isMail2925LimitReachedError(error) {
const message = getErrorMessage(error);
- const normalized = message.toLowerCase();
return message.startsWith(MAIL2925_LIMIT_ERROR_PREFIX)
- || normalized.includes('子邮箱已达上限')
- || normalized.includes('已达上限邮箱');
+ || message.includes('子邮箱已达上限')
+ || message.includes('已达上限邮箱');
}
function isMail2925ThreadTerminatedError(error) {
@@ -385,7 +384,7 @@
origins: MAIL2925_COOKIE_ORIGINS,
});
} catch (_) {
- // 这里只做尽力清理。
+ // Best effort cleanup only.
}
}
@@ -399,10 +398,14 @@
actionLabel = '确保 2925 邮箱登录态',
allowLoginWhenOnLoginPage = true,
} = options;
- const account = await ensureMail2925AccountForFlow({
- allowAllocate: true,
- preferredAccountId: accountId,
- });
+
+ let account = null;
+ if (forceRelogin) {
+ account = await ensureMail2925AccountForFlow({
+ allowAllocate: true,
+ preferredAccountId: accountId,
+ });
+ }
const sendLoginMessage = typeof sendToContentScriptResilient === 'function'
? sendToContentScriptResilient
@@ -416,13 +419,14 @@
}
);
- const buildSuccessPayload = async (result = {}) => ({
- account: await ensureMail2925AccountForFlow({
- allowAllocate: false,
- preferredAccountId: account.id,
- }),
+ const buildSuccessPayload = () => ({
+ account,
mail: getMail2925MailConfig(),
- result,
+ result: {
+ loggedIn: true,
+ currentView: 'mailbox',
+ usedExistingSession: true,
+ },
});
const failMailboxSession = async (message) => {
@@ -471,31 +475,24 @@
if (matchedLoginTab?.url) {
openedUrl = String(matchedLoginTab.url || '').trim();
}
- if (matchedLoginTab && 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)) {
await addLog('2925:当前邮箱页未跳转到登录页,将直接复用已登录会话。', 'info');
- return buildSuccessPayload({
- loggedIn: true,
- currentView: 'mailbox',
- currentUrl: openedUrl,
- usedExistingSession: true,
- });
+ return buildSuccessPayload();
}
if (!forceRelogin && !allowLoginWhenOnLoginPage) {
await failMailboxSession(`2925:${actionLabel}失败,当前页面已跳转到登录页,且当前未启用 2925 账号池,不执行自动登录。`);
}
+ if (!account) {
+ account = await ensureMail2925AccountForFlow({
+ allowAllocate: true,
+ preferredAccountId: accountId,
+ });
+ }
+
if (typeof ensureContentScriptReadyOnTab === 'function') {
await ensureContentScriptReadyOnTab(MAIL2925_SOURCE, tabId, {
inject: MAIL2925_INJECT,
@@ -531,7 +528,7 @@
timeoutMs: forceRelogin ? 30000 : 25000,
retryDelayMs: 800,
responseTimeoutMs: forceRelogin ? 30000 : 25000,
- logMessage: '步骤 0:2925 登录页通信异常,正在等待当前页面重新就绪后继续确认登录态...',
+ logMessage: '步骤 0:2925 登录页通信异常,正在等待页面恢复...',
}
);
} catch (err) {
@@ -547,7 +544,7 @@
await failMailboxSession(`2925:${actionLabel}失败(${result.error})。`);
}
if (result?.limitReached) {
- throw new Error(`${MAIL2925_LIMIT_ERROR_PREFIX}${result.limitMessage || '2925 子邮箱已达上限邮箱'}`);
+ throw new Error(`${MAIL2925_LIMIT_ERROR_PREFIX}${result.limitMessage || '子邮箱已达上限邮箱'}`);
}
if (!result?.loggedIn) {
await failMailboxSession(`2925:${actionLabel}失败,登录后仍未进入收件箱。`);
@@ -559,10 +556,18 @@
});
await setState({ currentMail2925AccountId: account.id });
broadcastDataUpdate({ currentMail2925AccountId: account.id });
+
const finalUrl = (await getMail2925TabUrlById(tabId)) || await getMail2925CurrentTabUrl();
await addLog(`2925:登录态确认成功,当前地址=${finalUrl || 'unknown'}`, 'ok');
- return buildSuccessPayload(result);
+ return {
+ account: await ensureMail2925AccountForFlow({
+ allowAllocate: false,
+ preferredAccountId: account.id,
+ }),
+ mail: getMail2925MailConfig(),
+ result,
+ };
}
async function handleMail2925LimitReachedError(step, error) {
@@ -570,10 +575,21 @@
|| '子邮箱已达上限邮箱';
const state = await getState();
const currentAccount = getCurrentMail2925Account(state);
+ const poolEnabled = Boolean(state?.mail2925UseAccountPool);
+
+ if (!poolEnabled) {
+ if (typeof requestStop === 'function') {
+ await requestStop({
+ logMessage: `步骤 ${step}:2925 检测到“${reason}”,当前未启用账号池,已按手动停止逻辑暂停自动流程。`,
+ });
+ }
+ return new Error('流程已被用户停止。');
+ }
+
if (!currentAccount) {
if (typeof requestStop === 'function') {
await requestStop({
- logMessage: `步骤 ${step}:2925 检测到“${reason}”,但当前没有可识别的账号可供处理。`,
+ logMessage: `步骤 ${step}:2925 检测到“${reason}”,但当前没有可识别的账号可供切换。`,
});
}
return new Error('流程已被用户停止。');
@@ -601,7 +617,7 @@
broadcastDataUpdate({ currentMail2925AccountId: null });
if (typeof requestStop === 'function') {
await requestStop({
- logMessage: `步骤 ${step}:2925 账号 ${currentAccount.email} 命中“${reason}”,且当前没有可切换的下一个账号。`,
+ logMessage: `步骤 ${step}:2925 账号 ${currentAccount.email} 命中“${reason}”,但当前没有可切换的下一个账号。`,
});
}
return new Error('流程已被用户停止。');
@@ -616,7 +632,7 @@
});
await addLog(`步骤 ${step}:2925 已切换到下一个账号 ${nextAccount.email}。`, 'warn');
return buildMail2925ThreadTerminatedError(
- `步骤 ${step}:2925 账号 ${currentAccount.email} 命中“${reason}”,已切换到 ${nextAccount.email},当前尝试结束,等待下一次重试。`
+ `步骤 ${step}:2925 账号 ${currentAccount.email} 命中“${reason}”,已切换到 ${nextAccount.email},当前尝试结束,等待下一轮重试。`
);
}
diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js
index e3f368b..9e602e7 100644
--- a/sidepanel/sidepanel.js
+++ b/sidepanel/sidepanel.js
@@ -295,24 +295,24 @@ function getManagedAliasProviderUiCopy(provider = selectMailProvider.value) {
}
if (String(provider || '').trim().toLowerCase() === GMAIL_PROVIDER) {
return {
- baseLabel: 'Gmail 原邮箱',
+ baseLabel: '基邮箱',
basePlaceholder: '例如 yourname@gmail.com',
buttonLabel: '生成',
successVerb: '生成',
label: 'Gmail +tag 邮箱',
placeholder: '点击生成 Gmail +tag 邮箱,或手动填写完整邮箱',
- hint: '先填写 Gmail 原邮箱后点“生成”,也可以直接手动填写完整的 Gmail 邮箱。',
+ hint: '先填写基邮箱后点“生成”,也可以直接手动填写完整的 Gmail 邮箱。',
};
}
if (String(provider || '').trim().toLowerCase() === '2925') {
return {
- baseLabel: '2925 基邮箱',
+ baseLabel: '基邮箱',
basePlaceholder: '例如 yourname@2925.com',
buttonLabel: '生成',
successVerb: '生成',
label: '2925 邮箱',
placeholder: '点击生成 2925 邮箱,或手动填写完整邮箱',
- hint: '先填写 2925 基邮箱后点“生成”,也可以直接手动填写完整的 2925 邮箱。',
+ hint: '先填写基邮箱后点“生成”,也可以直接手动填写完整的 2925 邮箱。',
};
}
return null;
diff --git a/tests/background-mail2925-entry-behavior.test.js b/tests/background-mail2925-entry-behavior.test.js
index dbec938..58fb798 100644
--- a/tests/background-mail2925-entry-behavior.test.js
+++ b/tests/background-mail2925-entry-behavior.test.js
@@ -72,6 +72,67 @@ test('ensureMail2925MailboxSession reuses current mailbox page without sending l
assert.equal(result.result.usedExistingSession, true);
});
+test('ensureMail2925MailboxSession does not require account-pool accounts when pool is off and mailbox page stays on mailList', async () => {
+ let currentState = {
+ autoRunning: false,
+ mail2925UseAccountPool: false,
+ mail2925Accounts: [],
+ currentMail2925AccountId: null,
+ };
+ let sendCalls = 0;
+
+ const manager = api.createMail2925SessionManager({
+ addLog: async () => {},
+ broadcastDataUpdate: () => {},
+ chrome: {
+ tabs: {
+ get: async () => ({ id: 9, url: 'https://2925.com/#/mailList' }),
+ },
+ cookies: {
+ getAll: async () => [],
+ remove: async () => ({ ok: true }),
+ },
+ browsingData: {
+ removeCookies: async () => {},
+ },
+ },
+ ensureContentScriptReadyOnTab: async () => {},
+ findMail2925Account: mail2925Utils.findMail2925Account,
+ getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus,
+ getState: async () => currentState,
+ isAutoRunLockedState: () => false,
+ isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable,
+ MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS,
+ normalizeMail2925Account: mail2925Utils.normalizeMail2925Account,
+ normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts,
+ pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun,
+ reuseOrCreateTab: async () => 9,
+ sendToMailContentScriptResilient: async () => {
+ sendCalls += 1;
+ return { loggedIn: true };
+ },
+ setPersistentSettings: async (payload) => {
+ currentState = { ...currentState, ...payload };
+ },
+ setState: async (updates) => {
+ currentState = { ...currentState, ...updates };
+ },
+ throwIfStopped: () => {},
+ upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList,
+ });
+
+ const result = await manager.ensureMail2925MailboxSession({
+ accountId: null,
+ forceRelogin: false,
+ allowLoginWhenOnLoginPage: false,
+ actionLabel: '步骤 4:确认 2925 邮箱登录态',
+ });
+
+ assert.equal(sendCalls, 0);
+ assert.equal(result.result.usedExistingSession, true);
+ assert.equal(result.account, null);
+});
+
test('ensureMail2925MailboxSession stops immediately when login page is detected and account pool is off', async () => {
let currentState = {
autoRunning: true,
diff --git a/tests/background-mail2925-relogin-wait.test.js b/tests/background-mail2925-relogin-wait.test.js
index c95d9f1..1f3cd4a 100644
--- a/tests/background-mail2925-relogin-wait.test.js
+++ b/tests/background-mail2925-relogin-wait.test.js
@@ -78,5 +78,5 @@ test('ensureMail2925MailboxSession waits 3 seconds before and after opening logi
assert.deepStrictEqual(events.openedUrls, ['https://2925.com/login/']);
assert.deepStrictEqual(events.sleeps, [3000, 3000]);
- assert.equal(events.readyCalls, 2);
+ assert.equal(events.readyCalls, 1);
});
diff --git a/tests/background-mail2925-session-module.test.js b/tests/background-mail2925-session-module.test.js
index 714c9ba..19188a7 100644
--- a/tests/background-mail2925-session-module.test.js
+++ b/tests/background-mail2925-session-module.test.js
@@ -17,6 +17,7 @@ test('handleMail2925LimitReachedError disables current account and switches to t
const api = new Function('self', `${source}; return self.MultiPageBackgroundMail2925Session;`)(globalScope);
let currentState = {
+ mail2925UseAccountPool: true,
mail2925Accounts: mail2925Utils.normalizeMail2925Accounts([
{ id: 'current', email: 'current@2925.com', password: 'p1', enabled: true, lastUsedAt: 10 },
{ id: 'next', email: 'next@2925.com', password: 'p2', enabled: true, lastUsedAt: 20 },
@@ -99,6 +100,7 @@ test('handleMail2925LimitReachedError requests stop when no next mail2925 accoun
const api = new Function('self', `${source}; return self.MultiPageBackgroundMail2925Session;`)(globalScope);
let currentState = {
+ mail2925UseAccountPool: true,
mail2925Accounts: mail2925Utils.normalizeMail2925Accounts([
{ id: 'only', email: 'only@2925.com', password: 'p1', enabled: true, lastUsedAt: 10 },
]),
@@ -225,3 +227,70 @@ test('ensureMail2925MailboxSession requests stop when auto run is active and log
assert.equal(events.stopCalls.length, 1);
assert.match(events.stopCalls[0].logMessage, /登录后仍未进入收件箱/);
});
+
+test('handleMail2925LimitReachedError stops immediately when account pool is off even if another account exists', async () => {
+ const source = fs.readFileSync('background/mail-2925-session.js', 'utf8');
+ const globalScope = {};
+ const api = new Function('self', `${source}; return self.MultiPageBackgroundMail2925Session;`)(globalScope);
+
+ let currentState = {
+ mail2925UseAccountPool: false,
+ mail2925Accounts: mail2925Utils.normalizeMail2925Accounts([
+ { id: 'current', email: 'current@2925.com', password: 'p1', enabled: true, lastUsedAt: 10 },
+ { id: 'next', email: 'next@2925.com', password: 'p2', enabled: true, lastUsedAt: 20 },
+ ]),
+ currentMail2925AccountId: 'current',
+ };
+ const events = {
+ stopCalls: [],
+ sessionChecks: 0,
+ };
+
+ const manager = api.createMail2925SessionManager({
+ addLog: async () => {},
+ broadcastDataUpdate: () => {},
+ chrome: {
+ cookies: {
+ getAll: async () => [],
+ remove: async () => ({ ok: true }),
+ },
+ browsingData: {
+ removeCookies: async () => {},
+ },
+ },
+ findMail2925Account: mail2925Utils.findMail2925Account,
+ getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus,
+ getState: async () => currentState,
+ isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable,
+ MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS,
+ normalizeMail2925Account: mail2925Utils.normalizeMail2925Account,
+ normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts,
+ pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun,
+ requestStop: async (options = {}) => {
+ events.stopCalls.push(options);
+ },
+ reuseOrCreateTab: async () => 1,
+ sendToMailContentScriptResilient: async () => {
+ events.sessionChecks += 1;
+ return { loggedIn: true };
+ },
+ setPersistentSettings: async (payload) => {
+ currentState = { ...currentState, ...payload };
+ },
+ setState: async (updates) => {
+ currentState = { ...currentState, ...updates };
+ },
+ throwIfStopped: () => {},
+ upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList,
+ });
+
+ const error = await manager.handleMail2925LimitReachedError(
+ 4,
+ new Error('MAIL2925_LIMIT_REACHED::子邮箱已达上限邮箱')
+ );
+
+ assert.equal(error.message, '流程已被用户停止。');
+ assert.equal(events.sessionChecks, 0);
+ assert.equal(events.stopCalls.length, 1);
+ assert.equal(currentState.currentMail2925AccountId, 'current');
+});
diff --git a/项目完整链路说明.md b/项目完整链路说明.md
index 4567a48..88385c0 100644
--- a/项目完整链路说明.md
+++ b/项目完整链路说明.md
@@ -331,7 +331,7 @@
补充行为:
- `2925` provider 会关闭 Step 4 / 8 的自动重发间隔 25 秒节流;每次“重新发送验证码”之间,会在邮箱页内部执行一轮固定 15 次的刷新轮询,不再因 OAuth 剩余时间预算而缩短。
-- 当 provider 为 `2925` 时,Step 4 会优先直接打开当前 2925 邮箱页:如果页面仍停留在收件箱,就直接复用当前已登录页面;如果页面已经跳到登录页,则只有在启用了 2925 账号池时才会自动登录,未启用账号池时会直接报错停止。Step 8 不再额外承接这套登录态处理。
+- 当 provider 为 `2925` 时,Step 4 会优先直接打开当前 2925 邮箱页:如果页面仍停留在收件箱,就直接复用当前已登录页面;如果页面已经跳到登录页,则只有在启用了 2925 账号池时才会自动登录,未启用账号池时会直接复用现有停止逻辑结束流程。Step 8 不再额外承接这套登录态处理。
- 普通邮箱仍会携带 `filterAfterTimestamp` 做时间窗筛选;`2925` 当前既不依赖时间窗,也不再做“新旧邮件快照差集”比较,而是每次刷新后直接遍历当前列表中的匹配邮件。
- 自动重新发送验证码次数现在使用 sidepanel 里的单一“验证码重发”配置;普通邮箱仍按 25 秒间隔节流,Hotmail / 2925 不走这个 25 秒间隔。Step 4 若启用先请求新验证码,会先消耗一次当前步骤的自动重发次数。
- 验证码提交重试上限当前为 15 次;页面明确拒绝验证码时,会在上限内继续拉取新验证码并重提。
@@ -555,8 +555,8 @@
1. 用户在 sidepanel 的 2925 账号池中保存 `email / password`
2. 只有当 sidepanel 中的 `mail2925UseAccountPool` 开关开启时,别名基邮箱才会优先取当前账号池选中的 2925 账号邮箱;关闭时会回退到原来的手填 `mail2925BaseEmail`
-3. 手动点击 `登录` 或自动流程进入 Step 4 前,后台会先打开当前 2925 邮箱页:如果仍停留在收件箱则直接复用;如果跳到登录页,则仅在号池模式开启时才自动登录,关闭号池时直接停止
-4. 一旦轮询期间出现“子邮箱已达上限邮箱”,后台会先记录当前时间;若当前处于号池模式且还有下一个可用账号,则把当前账号禁用 24 小时并自动切到下一个账号重新登录
+3. 手动点击 `登录` 或自动流程进入 Step 4 前,后台会先打开当前 2925 邮箱页:如果仍停留在收件箱则直接复用;如果跳到登录页,则仅在号池模式开启时才自动登录,关闭号池时直接调用现有停止逻辑结束流程
+4. 一旦轮询期间出现“子邮箱已达上限邮箱”,后台会先判断是否启用了号池模式:若已启用且还有下一个可用账号,则把当前账号禁用 24 小时并自动切到下一个账号重新登录;若未启用,则直接调用现有停止逻辑结束流程
5. 如果登录提交后 20 秒内仍未进入收件箱,且当前正处于自动运行中,则后台会直接复用现有 `requestStop()` 停止链路,把整个自动流程停成和用户手动点击“停止”一致的状态;这类情况常见于图片验证、行为验证或其他阻断登录的中间页
6. 如果没有下一个可用账号,或当前未启用号池模式,则不会继续消耗自动重试次数,而是直接复用现有 `requestStop()` 停止链路,把整个自动流程停成和用户手动点击“停止”一致的状态
7. sidepanel 中 2925 账号池的新增表单也走与 Hotmail 相同的共享交互:默认收起,头部按钮切换“添加账号 / 取消添加”,操作行右侧提供“批量导入”,保存成功后自动收起并清空。
From 3abc9ff9f940d33327ccaeea3c18b3a8a49cef25 Mon Sep 17 00:00:00 2001
From: QLHazyCoder <2825305047@qq.com>
Date: Tue, 21 Apr 2026 21:30:55 +0800
Subject: [PATCH 07/23] chore(release): bump version to Pro5.4
---
manifest.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/manifest.json b/manifest.json
index dd4c5b7..733a5a4 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "多页面自动化",
- "version": "5.3",
- "version_name": "Pro5.3",
+ "version": "5.4",
+ "version_name": "Pro5.4",
"description": "用于自动执行多步骤 OAuth 注册流程",
"permissions": [
"sidePanel",
From ab935a33b14331cd55c7e939b8b132850953c8fa Mon Sep 17 00:00:00 2001
From: QLHazyCoder <2825305047@qq.com>
Date: Tue, 21 Apr 2026 22:06:34 +0800
Subject: [PATCH 08/23] chore(release): bump version to Pro5.5
---
manifest.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/manifest.json b/manifest.json
index 733a5a4..255378b 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "多页面自动化",
- "version": "5.4",
- "version_name": "Pro5.4",
+ "version": "5.5",
+ "version_name": "Pro5.5",
"description": "用于自动执行多步骤 OAuth 注册流程",
"permissions": [
"sidePanel",
From cf42dc73fd22d60534a99311c709604f43bc5a40 Mon Sep 17 00:00:00 2001
From: QLHazyCoder <2825305047@qq.com>
Date: Tue, 21 Apr 2026 23:21:19 +0800
Subject: [PATCH 09/23] =?UTF-8?q?2925=E9=82=AE=E7=AE=B1=E6=B7=BB=E5=8A=A0?=
=?UTF-8?q?=E9=82=AE=E7=AE=B1=E6=8E=A5=E6=94=B6=E5=92=8C=E5=8F=91=E9=80=81?=
=?UTF-8?q?=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 34 +++---
background.js | 24 +++-
background/generated-email-helpers.js | 8 +-
background/verification-flow.js | 4 +
content/mail-2925.js | 47 ++++++++
managed-alias-utils.js | 29 +++++
sidepanel/sidepanel.html | 19 +--
sidepanel/sidepanel.js | 44 +++++--
.../background-generated-email-module.test.js | 61 ++++++++++
tests/mail-2925-content.test.js | 93 ++++++++++++++-
tests/managed-alias-utils.test.js | 9 ++
tests/sidepanel-mail2925-mode.test.js | 109 ++++++++++++++++++
tests/verification-flow-polling.test.js | 43 +++++++
项目完整链路说明.md | 34 +++---
项目文件结构说明.md | 15 +--
15 files changed, 515 insertions(+), 58 deletions(-)
create mode 100644 tests/sidepanel-mail2925-mode.test.js
diff --git a/README.md b/README.md
index 967bb11..6da734c 100644
--- a/README.md
+++ b/README.md
@@ -81,33 +81,34 @@
## 安装
1. 打开 `chrome://extensions/`
+2. 开启“开发者模式”
+3. 点击“加载已解压的扩展程序”
+4. 选择本项目目录
+5. 打开扩展侧边栏
## 2026-04-17 更新补充:Gmail / 2925 别名邮箱
-本次版本对 `Gmail` 与 `2925` 的注册邮箱逻辑做了统一整理:
+本次版本对 `Gmail` 与 `2925 provide` 的注册邮箱逻辑做了统一整理:
-- `Gmail` 与 `2925` 现在都走同一套“别名邮箱”逻辑。
+- `Gmail` 与 `2925 provide` 现在都走同一套“别名邮箱”逻辑。
- 两者都不再使用“只填前缀再特殊拼接”的界面交互。
- 两者都要求先填写“基邮箱”:
- `Gmail`:例如 `name@gmail.com`
- - `2925`:例如 `name@2925.com`
+ - `2925`(仅 provide 模式):例如 `name@2925.com`
- 侧边栏里的“注册邮箱”输入框对这两种模式都已开放,可直接手动填写完整邮箱。
- 侧边栏里的 `获取 / 生成` 按钮对这两种模式也可用,行为与 Duck / Cloudflare 一样,都是“可自动生成,也可手动覆盖”。
+- 当 `Mail = 2925` 且模式切到 `接收邮箱` 时,不再走别名基邮箱链路,而是回退到普通“邮箱生成 / 手动填写注册邮箱”路线,2925 只负责后续收信。
具体行为:
- `Gmail` 会基于完整基邮箱生成 `name+tag@gmail.com`
-- `2925` 会基于完整基邮箱生成 `name123456@2925.com`
+- `2925` 仅在 provide 模式下会基于完整基邮箱生成 `name123456@2925.com`
- 如果当前“注册邮箱”里已经是与当前基邮箱兼容的完整邮箱,流程会优先复用,不会强行重新生成
注意:
- `2925` 旧的“只填前缀”使用方式已经不再推荐,应该改为填写完整基邮箱
-- 如果你手动填写了与当前 `Gmail / 2925` 基邮箱不匹配的完整邮箱,侧边栏会在保存或执行 Step 3 时拦截
-2. 开启“开发者模式”
-3. 点击“加载已解压的扩展程序”
-4. 选择本项目目录
-5. 打开扩展侧边栏
+- 如果你手动填写了与当前 `Gmail / 2925 provide` 基邮箱不匹配的完整邮箱,侧边栏会在保存或执行 Step 3 时拦截
## 快速开始
@@ -143,12 +144,15 @@
1. `Mail` 选择 `2925`
2. 在 `2925 账号池` 中添加 `邮箱 / 密码`
-3. 在“2925 基邮箱”右侧打开 `号池` 开关后,基邮箱输入框会切成下拉框,只能从 2925 账号池中选择邮箱;关闭开关则回退到原来的手填基邮箱路线
-4. 可先点 `使用此账号` 让当前别名基邮箱切到这条 2925 账号,再点 `登录` 手动验证网页邮箱登录态
-5. 只有在 `号池` 开关开启时,自动流程执行到 Step 4 / Step 8 前才会自动检查 2925 登录态;如果未登录,会先清理登录 cookie、等待 `3 秒`,再打开登录页,并在页面打开后再等待 `3 秒`,然后使用当前账号自动登录;点击登录后若 `20 秒`内仍未进入收件箱,则会判定当前登录失败
-6. 当 Step 4 / Step 8 轮询邮箱时遇到“子邮箱已达上限邮箱”,扩展会记录当前时间;如果还有下一个可用账号,就禁用当前账号 24 小时并自动切换登录;如果没有下一个可用账号,或当前未启用号池模式,则会直接复用现有“手动暂停 / 停止”逻辑终止自动流程
-7. 如果你同时开启了 `Auto` 的自动重试,当前尝试结束后会按现有逻辑自动进入下一次尝试,不需要再手动介入
-8. `Mail = 2925` 仍然走 Gmail / 2925 共用的别名邮箱链路;实际注册邮箱会基于当前 2925 账号邮箱生成,例如 `name@2925.com -> name123456@2925.com`
+3. 先根据你的用途选择 `2925 模式`
+ - `提供邮箱`:注册邮箱本身就是 2925 别名,会显示“别名基邮箱”输入
+ - `接收邮箱`:注册邮箱回退到普通“邮箱生成 / 手动填写”路线,2925 只负责收信
+4. `2925 号池` 现在是独立配置行;开启 `号池` 后可从下拉框中选择当前 2925 账号。若当前处于 `提供邮箱` 模式,这个账号也会同步作为别名基邮箱
+5. 可先点 `使用此账号` 让当前 2925 账号切到这条记录,再点 `登录` 手动验证网页邮箱登录态
+6. 只有在 `号池` 开关开启时,自动流程执行到 Step 4 / Step 8 前才会自动检查 2925 登录态;如果未登录,会先清理登录 cookie、等待 `3 秒`,再打开登录页,并在页面打开后再等待 `3 秒`,然后使用当前账号自动登录;点击登录后若 `20 秒`内仍未进入收件箱,则会判定当前登录失败
+7. 当 Step 4 / Step 8 轮询邮箱时遇到“子邮箱已达上限邮箱”,扩展会记录当前时间;如果还有下一个可用账号,就禁用当前账号 24 小时并自动切换登录;如果没有下一个可用账号,或当前未启用号池模式,则会直接复用现有“手动暂停 / 停止”逻辑终止自动流程
+8. 如果你同时开启了 `Auto` 的自动重试,当前尝试结束后会按现有逻辑自动进入下一次尝试,不需要再手动介入
+9. 只有 `Mail = 2925` 且模式为 `提供邮箱` 时,才会继续走 Gmail / 2925 共用的别名邮箱链路;例如 `name@2925.com -> name123456@2925.com`
## 侧边栏配置说明
diff --git a/background.js b/background.js
index 83b9e09..eb23563 100644
--- a/background.js
+++ b/background.js
@@ -2132,11 +2132,21 @@ function isGeneratedAliasProvider(stateOrProvider, mail2925Mode = undefined) {
const provider = typeof stateOrProvider === 'string'
? stateOrProvider
: stateOrProvider?.mailProvider;
+ const resolvedMail2925Mode = mail2925Mode !== undefined
+ ? normalizeMail2925Mode(mail2925Mode)
+ : getMail2925Mode(stateOrProvider);
const utils = (typeof self !== 'undefined' ? self : globalThis).MultiPageManagedAliasUtils || null;
+ if (utils?.usesManagedAliasGeneration) {
+ return utils.usesManagedAliasGeneration(provider, { mail2925Mode: resolvedMail2925Mode });
+ }
if (utils?.isManagedAliasProvider) {
+ if (String(provider || '').trim().toLowerCase() === '2925') {
+ return utils.isManagedAliasProvider(provider) && resolvedMail2925Mode === MAIL_2925_MODE_PROVIDE;
+ }
return utils.isManagedAliasProvider(provider);
}
- return provider === GMAIL_PROVIDER || provider === '2925';
+ return provider === GMAIL_PROVIDER
+ || (provider === '2925' && resolvedMail2925Mode === MAIL_2925_MODE_PROVIDE);
}
function shouldUseCustomRegistrationEmail(state = {}) {
@@ -2279,11 +2289,21 @@ function isGeneratedAliasProvider(stateOrProvider, mail2925Mode = undefined) {
const provider = typeof stateOrProvider === 'string'
? stateOrProvider
: stateOrProvider?.mailProvider;
+ const resolvedMail2925Mode = mail2925Mode !== undefined
+ ? normalizeMail2925Mode(mail2925Mode)
+ : getMail2925Mode(stateOrProvider);
const utils = getManagedAliasUtils();
+ if (utils?.usesManagedAliasGeneration) {
+ return utils.usesManagedAliasGeneration(provider, { mail2925Mode: resolvedMail2925Mode });
+ }
if (utils?.isManagedAliasProvider) {
+ if (String(provider || '').trim().toLowerCase() === '2925') {
+ return utils.isManagedAliasProvider(provider) && resolvedMail2925Mode === MAIL_2925_MODE_PROVIDE;
+ }
return utils.isManagedAliasProvider(provider);
}
- return provider === GMAIL_PROVIDER || provider === '2925';
+ return provider === GMAIL_PROVIDER
+ || (provider === '2925' && resolvedMail2925Mode === MAIL_2925_MODE_PROVIDE);
}
function shouldUseCustomRegistrationEmail(state = {}) {
diff --git a/background/generated-email-helpers.js b/background/generated-email-helpers.js
index be98af5..3d924a7 100644
--- a/background/generated-email-helpers.js
+++ b/background/generated-email-helpers.js
@@ -195,6 +195,9 @@
...(state || {}),
mailProvider: provider,
};
+ if (options.mail2925Mode !== undefined) {
+ mergedState.mail2925Mode = String(options.mail2925Mode || '').trim();
+ }
if (options.gmailBaseEmail !== undefined) {
mergedState.gmailBaseEmail = String(options.gmailBaseEmail || '').trim();
}
@@ -227,7 +230,10 @@
async function fetchGeneratedEmail(state, options = {}) {
const currentState = state || await getState();
const provider = String(options.mailProvider || currentState.mailProvider || '').trim().toLowerCase();
- if (isGeneratedAliasProvider?.(provider)) {
+ const mail2925Mode = options.mail2925Mode !== undefined
+ ? options.mail2925Mode
+ : currentState.mail2925Mode;
+ if (isGeneratedAliasProvider?.(provider, mail2925Mode)) {
return fetchManagedAliasEmail(currentState, options);
}
const generator = normalizeEmailGenerator(options.generator ?? currentState.emailGenerator);
diff --git a/background/verification-flow.js b/background/verification-flow.js
index fc4f208..eba621d 100644
--- a/background/verification-flow.js
+++ b/background/verification-flow.js
@@ -113,12 +113,15 @@
function getVerificationPollPayload(step, state, overrides = {}) {
const is2925Provider = state?.mailProvider === '2925';
+ const mail2925MatchTargetEmail = is2925Provider
+ && String(state?.mail2925Mode || '').trim().toLowerCase() === 'receive';
if (step === 4) {
return {
filterAfterTimestamp: is2925Provider ? 0 : getHotmailVerificationRequestTimestamp(4, state),
senderFilters: ['openai', 'noreply', 'verify', 'auth', 'duckduckgo', 'forward'],
subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm'],
targetEmail: state.email,
+ mail2925MatchTargetEmail,
maxAttempts: is2925Provider ? MAIL_2925_VERIFICATION_MAX_ATTEMPTS : 5,
intervalMs: is2925Provider ? MAIL_2925_VERIFICATION_INTERVAL_MS : 3000,
...overrides,
@@ -130,6 +133,7 @@
senderFilters: ['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'],
subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm', 'login'],
targetEmail: String(state?.step8VerificationTargetEmail || '').trim() || state.email,
+ mail2925MatchTargetEmail,
maxAttempts: is2925Provider ? MAIL_2925_VERIFICATION_MAX_ATTEMPTS : 5,
intervalMs: is2925Provider ? MAIL_2925_VERIFICATION_INTERVAL_MS : 3000,
...overrides,
diff --git a/content/mail-2925.js b/content/mail-2925.js
index 54970d2..1c3a6b0 100644
--- a/content/mail-2925.js
+++ b/content/mail-2925.js
@@ -629,6 +629,39 @@ function extractVerificationCode(text, strictChatGPTCodeOnly = false) {
return null;
}
+function extractEmails(text = '') {
+ const matches = String(text || '').match(/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi) || [];
+ return [...new Set(matches.map((item) => item.toLowerCase()))];
+}
+
+function emailMatchesTarget(candidate, targetEmail) {
+ const normalizedCandidate = String(candidate || '').trim().toLowerCase();
+ const normalizedTarget = String(targetEmail || '').trim().toLowerCase();
+ return Boolean(normalizedCandidate && normalizedTarget && normalizedCandidate === normalizedTarget);
+}
+
+function getTargetEmailMatchState(text, targetEmail) {
+ const normalizedTarget = String(targetEmail || '').trim().toLowerCase();
+ if (!normalizedTarget) {
+ return { matches: true, hasExplicitEmail: false };
+ }
+
+ const normalizedText = String(text || '').toLowerCase();
+ if (normalizedText.includes(normalizedTarget)) {
+ return { matches: true, hasExplicitEmail: true };
+ }
+
+ const extractedEmails = extractEmails(normalizedText);
+ if (!extractedEmails.length) {
+ return { matches: true, hasExplicitEmail: false };
+ }
+
+ return {
+ matches: extractedEmails.some((candidate) => emailMatchesTarget(candidate, normalizedTarget)),
+ hasExplicitEmail: true,
+ };
+}
+
function normalizeMinuteTimestamp(timestamp) {
if (!Number.isFinite(timestamp) || timestamp <= 0) return 0;
const date = new Date(timestamp);
@@ -930,6 +963,8 @@ async function handlePollEmail(step, payload) {
filterAfterTimestamp = 0,
excludeCodes = [],
strictChatGPTCodeOnly = false,
+ targetEmail = '',
+ mail2925MatchTargetEmail = false,
} = payload || {};
const excludedCodeSet = new Set(excludeCodes.filter(Boolean));
const filterAfterMinute = normalizeMinuteTimestamp(Number(filterAfterTimestamp) || 0);
@@ -994,9 +1029,21 @@ async function handlePollEmail(step, payload) {
if (!matchesMailFilters(previewText, senderFilters, subjectFilters)) {
continue;
}
+ const previewTargetState = mail2925MatchTargetEmail
+ ? getTargetEmailMatchState(previewText, targetEmail)
+ : { matches: true, hasExplicitEmail: false };
+ if (mail2925MatchTargetEmail && previewTargetState.hasExplicitEmail && !previewTargetState.matches) {
+ continue;
+ }
const previewCode = extractVerificationCode(previewText, strictChatGPTCodeOnly);
const openedText = await openMailAndDeleteAfterRead(item, step);
+ const openedTargetState = mail2925MatchTargetEmail
+ ? getTargetEmailMatchState(openedText, targetEmail)
+ : { matches: true, hasExplicitEmail: false };
+ if (mail2925MatchTargetEmail && openedTargetState.hasExplicitEmail && !openedTargetState.matches) {
+ continue;
+ }
const bodyCode = extractVerificationCode(openedText, strictChatGPTCodeOnly);
const candidateCode = bodyCode || previewCode;
diff --git a/managed-alias-utils.js b/managed-alias-utils.js
index 5213097..51e8c69 100644
--- a/managed-alias-utils.js
+++ b/managed-alias-utils.js
@@ -3,6 +3,9 @@
})(typeof self !== 'undefined' ? self : globalThis, function createManagedAliasUtilsModule() {
const GMAIL_PROVIDER = 'gmail';
const MAIL_2925_PROVIDER = '2925';
+ const MAIL_2925_MODE_PROVIDE = 'provide';
+ const MAIL_2925_MODE_RECEIVE = 'receive';
+ const DEFAULT_MAIL_2925_MODE = MAIL_2925_MODE_PROVIDE;
const PROVIDER_CONFIGS = {
[GMAIL_PROVIDER]: {
@@ -64,10 +67,31 @@
return PROVIDER_CONFIGS[String(provider || '').trim().toLowerCase()] || null;
}
+ function normalizeMail2925Mode(value = '') {
+ return String(value || '').trim().toLowerCase() === MAIL_2925_MODE_RECEIVE
+ ? MAIL_2925_MODE_RECEIVE
+ : DEFAULT_MAIL_2925_MODE;
+ }
+
function isManagedAliasProvider(provider = '') {
return Boolean(getManagedAliasProviderConfig(provider));
}
+ function usesManagedAliasGeneration(provider = '', options = {}) {
+ const normalizedProvider = String(provider || '').trim().toLowerCase();
+ if (!isManagedAliasProvider(normalizedProvider)) {
+ return false;
+ }
+ if (normalizedProvider !== MAIL_2925_PROVIDER) {
+ return true;
+ }
+
+ const mail2925Mode = typeof options === 'string'
+ ? options
+ : options?.mail2925Mode;
+ return normalizeMail2925Mode(mail2925Mode) === MAIL_2925_MODE_PROVIDE;
+ }
+
function parseEmailParts(rawValue = '') {
const value = String(rawValue || '').trim().toLowerCase();
const match = value.match(/^([^@\s]+)@([^@\s]+\.[^@\s]+)$/);
@@ -136,11 +160,16 @@
return {
buildManagedAliasEmail,
+ DEFAULT_MAIL_2925_MODE,
getManagedAliasProviderConfig,
getManagedAliasProviderUiCopy,
isManagedAliasEmail,
isManagedAliasProvider,
+ MAIL_2925_MODE_PROVIDE,
+ MAIL_2925_MODE_RECEIVE,
+ normalizeMail2925Mode,
parseEmailParts,
parseManagedAliasBaseEmail,
+ usesManagedAliasGeneration,
};
});
diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html
index ffd6bc0..c8f4b05 100644
--- a/sidepanel/sidepanel.html
+++ b/sidepanel/sidepanel.html
@@ -269,20 +269,25 @@
-
别名基邮箱
+
+
diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js
index 9e602e7..6c497fa 100644
--- a/sidepanel/sidepanel.js
+++ b/sidepanel/sidepanel.js
@@ -98,6 +98,7 @@ const rowCustomPassword = document.getElementById('row-custom-password');
const selectMailProvider = document.getElementById('select-mail-provider');
const btnMailLogin = document.getElementById('btn-mail-login');
const rowMail2925Mode = document.getElementById('row-mail-2925-mode');
+const rowMail2925PoolSettings = document.getElementById('row-mail2925-pool-settings');
const mail2925ModeButtons = Array.from(document.querySelectorAll('[data-mail2925-mode]'));
const rowEmailGenerator = document.getElementById('row-email-generator');
const selectEmailGenerator = document.getElementById('select-email-generator');
@@ -264,12 +265,24 @@ function getManagedAliasUtils() {
return window.MultiPageManagedAliasUtils || null;
}
-function isManagedAliasProvider(provider = selectMailProvider.value) {
+function isManagedAliasProvider(provider = selectMailProvider.value, mail2925Mode = getSelectedMail2925Mode()) {
const utils = getManagedAliasUtils();
+ if (utils?.usesManagedAliasGeneration) {
+ return utils.usesManagedAliasGeneration(provider, { mail2925Mode });
+ }
if (utils?.isManagedAliasProvider) {
+ const normalizedProvider = String(provider || '').trim().toLowerCase();
+ if (normalizedProvider === '2925') {
+ return utils.isManagedAliasProvider(provider)
+ && normalizeMail2925Mode(mail2925Mode) === MAIL_2925_MODE_PROVIDE;
+ }
return utils.isManagedAliasProvider(provider);
}
- return [GMAIL_PROVIDER, '2925'].includes(String(provider || '').trim().toLowerCase());
+ const normalizedProvider = String(provider || '').trim().toLowerCase();
+ if (normalizedProvider === '2925') {
+ return normalizeMail2925Mode(mail2925Mode) === MAIL_2925_MODE_PROVIDE;
+ }
+ return normalizedProvider === GMAIL_PROVIDER;
}
function parseManagedAliasBaseEmail(rawValue, provider = selectMailProvider.value) {
@@ -288,7 +301,10 @@ function isManagedAliasEmail(value, baseEmail = '', provider = selectMailProvide
return false;
}
-function getManagedAliasProviderUiCopy(provider = selectMailProvider.value) {
+function getManagedAliasProviderUiCopy(provider = selectMailProvider.value, mail2925Mode = getSelectedMail2925Mode()) {
+ if (!isManagedAliasProvider(provider, mail2925Mode)) {
+ return null;
+ }
const utils = getManagedAliasUtils();
if (utils?.getManagedAliasProviderUiCopy) {
return utils.getManagedAliasProviderUiCopy(provider);
@@ -592,7 +608,7 @@ const LOG_LEVEL_LABELS = {
};
function usesGeneratedAliasMailProvider(provider, mail2925Mode = getSelectedMail2925Mode()) {
- return isManagedAliasProvider(provider);
+ return isManagedAliasProvider(provider, mail2925Mode);
}
function parseGmailBaseEmail(rawValue = '') {
@@ -2639,13 +2655,18 @@ function updateMailProviderUI() {
const useIcloudProvider = isIcloudMailProvider();
const useEmailGenerator = !useHotmail && !useLuckmail && !useGeneratedAlias && !useCustomEmail;
const useCloudflareTempEmailProvider = selectMailProvider.value === 'cloudflare-temp-email';
- const aliasUiCopy = useGeneratedAlias ? getManagedAliasProviderUiCopy(selectMailProvider.value) : null;
+ const aliasUiCopy = useGeneratedAlias
+ ? getManagedAliasProviderUiCopy(selectMailProvider.value, mail2925Mode)
+ : null;
const uiCopy = getCurrentRegistrationEmailUiCopy();
updateMailLoginButtonState();
if (rowMail2925Mode) {
- rowMail2925Mode.style.display = 'none';
+ rowMail2925Mode.style.display = use2925 ? '' : 'none';
}
- rowEmailPrefix.style.display = useGeneratedAlias ? '' : 'none';
+ if (rowMail2925PoolSettings) {
+ rowMail2925PoolSettings.style.display = useMail2925 ? '' : 'none';
+ }
+ rowEmailPrefix.style.display = useGeneratedAlias && !useMail2925AccountPool ? '' : 'none';
const hotmailServiceMode = getSelectedHotmailServiceMode();
rowInbucketHost.style.display = useInbucket ? '' : 'none';
rowInbucketMailbox.style.display = useInbucket ? '' : 'none';
@@ -2708,8 +2729,8 @@ function updateMailProviderUI() {
selectMail2925PoolAccount.style.display = useMail2925AccountPool ? '' : 'none';
selectMail2925PoolAccount.disabled = !useMail2925AccountPool || getMail2925Accounts().length === 0;
}
- inputEmailPrefix.style.display = useMail2925AccountPool ? 'none' : '';
- inputEmailPrefix.readOnly = useMail2925AccountPool;
+ inputEmailPrefix.style.display = '';
+ inputEmailPrefix.readOnly = false;
selectEmailGenerator.disabled = useHotmail || useLuckmail || useGeneratedAlias || useCustomEmail;
if (useGmail) {
labelEmailPrefix.textContent = 'Gmail 原邮箱';
@@ -2760,7 +2781,9 @@ function updateMailProviderUI() {
}
if (autoHintText && useMail2925AccountPool) {
autoHintText.textContent = getMail2925Accounts().length
- ? '当前已启用 2925 号池模式,步骤 3 会基于下拉框选中的号池邮箱生成别名地址'
+ ? (useGeneratedAlias
+ ? '当前已启用 2925 号池模式,步骤 3 会基于下拉框选中的号池邮箱生成别名地址'
+ : '当前已启用 2925 号池模式,步骤 4 / 8 遇到登录页时会优先使用下拉框选中的账号自动登录')
: '当前已启用 2925 号池模式,请先在下方 2925 账号池中添加账号并选择邮箱';
}
if (autoHintText && showCloudflareTempEmailReceiveMailbox) {
@@ -3088,6 +3111,7 @@ async function fetchGeneratedEmail(options = {}) {
generateNew: true,
generator: selectEmailGenerator.value,
mailProvider: selectMailProvider.value,
+ mail2925Mode: getSelectedMail2925Mode(),
...buildManagedAliasBaseEmailPayload(),
},
});
diff --git a/tests/background-generated-email-module.test.js b/tests/background-generated-email-module.test.js
index 2f79ed2..fb5d764 100644
--- a/tests/background-generated-email-module.test.js
+++ b/tests/background-generated-email-module.test.js
@@ -15,3 +15,64 @@ test('generated email helper module exposes a factory', () => {
assert.equal(typeof api?.createGeneratedEmailHelpers, 'function');
});
+
+test('generated email helper falls back to normal generator when 2925 is in receive mode', async () => {
+ const source = fs.readFileSync('background/generated-email-helpers.js', 'utf8');
+ const globalScope = {};
+ const api = new Function('self', `${source}; return self.MultiPageGeneratedEmailHelpers;`)(globalScope);
+ const events = [];
+
+ const helpers = api.createGeneratedEmailHelpers({
+ addLog: async () => {},
+ buildGeneratedAliasEmail: () => {
+ throw new Error('should not build alias in receive mode');
+ },
+ buildCloudflareTempEmailHeaders: () => ({}),
+ CLOUDFLARE_TEMP_EMAIL_GENERATOR: 'cloudflare-temp-email',
+ DUCK_AUTOFILL_URL: 'https://duckduckgo.com/email',
+ fetch: async () => ({ ok: true, text: async () => '{}' }),
+ fetchIcloudHideMyEmail: async () => {
+ throw new Error('should not use icloud generator');
+ },
+ getCloudflareTempEmailAddressFromResponse: () => '',
+ getCloudflareTempEmailConfig: () => ({ baseUrl: '', adminAuth: '', domain: '' }),
+ getState: async () => ({
+ mailProvider: '2925',
+ mail2925Mode: 'receive',
+ emailGenerator: 'duck',
+ }),
+ ensureMail2925AccountForFlow: async () => {
+ throw new Error('should not allocate 2925 account in receive mode');
+ },
+ joinCloudflareTempEmailUrl: () => '',
+ normalizeCloudflareDomain: () => '',
+ normalizeCloudflareTempEmailAddress: () => '',
+ normalizeEmailGenerator: (value) => String(value || '').trim().toLowerCase(),
+ isGeneratedAliasProvider: (_provider, mail2925Mode) => mail2925Mode === 'provide',
+ reuseOrCreateTab: async () => {},
+ sendToContentScript: async (_source, message) => {
+ events.push(message.type);
+ return { email: 'duck@example.com', generated: true };
+ },
+ setEmailState: async (email) => {
+ events.push(['email', email]);
+ },
+ throwIfStopped: () => {},
+ });
+
+ const email = await helpers.fetchGeneratedEmail({
+ mailProvider: '2925',
+ mail2925Mode: 'receive',
+ emailGenerator: 'duck',
+ }, {
+ mailProvider: '2925',
+ mail2925Mode: 'receive',
+ generator: 'duck',
+ });
+
+ assert.equal(email, 'duck@example.com');
+ assert.deepStrictEqual(events, [
+ 'FETCH_DUCK_EMAIL',
+ ['email', 'duck@example.com'],
+ ]);
+});
diff --git a/tests/mail-2925-content.test.js b/tests/mail-2925-content.test.js
index 09730be..0349208 100644
--- a/tests/mail-2925-content.test.js
+++ b/tests/mail-2925-content.test.js
@@ -164,7 +164,7 @@ return {
assert.deepEqual(api.getReadAndDeleteCalls(), ['baseline', 'new']);
});
-test('handlePollEmail ignores targetEmail and still tests any matching ChatGPT mail', async () => {
+test('handlePollEmail keeps ignoring targetEmail when receive-mode matching is disabled', async () => {
const bundle = [
extractFunction('normalizeMinuteTimestamp'),
extractFunction('handlePollEmail'),
@@ -242,12 +242,103 @@ return {
maxAttempts: 4,
intervalMs: 1,
targetEmail: 'expected@example.com',
+ mail2925MatchTargetEmail: false,
});
assert.equal(result.code, '112233');
assert.deepEqual(api.getReadAndDeleteCalls(), ['mail-1']);
});
+test('handlePollEmail skips explicit mismatched target emails when receive-mode matching is enabled', async () => {
+ const bundle = [
+ extractFunction('extractEmails'),
+ extractFunction('emailMatchesTarget'),
+ extractFunction('getTargetEmailMatchState'),
+ extractFunction('normalizeMinuteTimestamp'),
+ extractFunction('handlePollEmail'),
+ ].join('\n');
+
+ const api = new Function(`
+let state = 'ready';
+const seenCodes = new Set();
+const readAndDeleteCalls = [];
+const mismatchMail = {
+ id: 'mail-1',
+ text: 'ChatGPT verification code 112233 for another.user@example.com',
+};
+const targetMail = {
+ id: 'mail-2',
+ text: 'ChatGPT verification code 445566 for expected@example.com',
+};
+
+function findMailItems() {
+ return state === 'ready' ? [mismatchMail, targetMail] : [];
+}
+
+function getMailItemId(item) {
+ return item.id;
+}
+
+function getCurrentMailIds(items = []) {
+ return new Set(items.map((item) => item.id));
+}
+
+function parseMailItemTimestamp() {
+ return Date.now();
+}
+
+function matchesMailFilters(text) {
+ return /chatgpt|openai|verification/i.test(String(text || ''));
+}
+
+function getMailItemText(item) {
+ return item.text;
+}
+
+function extractVerificationCode(text) {
+ const match = String(text || '').match(/(\\d{6})/);
+ return match ? match[1] : null;
+}
+
+async function sleep() {}
+async function sleepRandom() {}
+async function returnToInbox() {
+ return true;
+}
+async function refreshInbox() {}
+
+async function openMailAndDeleteAfterRead(item) {
+ readAndDeleteCalls.push(item.id);
+ return item.text;
+}
+
+async function ensureSeenCodesSession() {}
+function persistSeenCodes() {}
+function log() {}
+
+${bundle}
+
+return {
+ handlePollEmail,
+ getReadAndDeleteCalls() {
+ return readAndDeleteCalls.slice();
+ },
+};
+`)();
+
+ const result = await api.handlePollEmail(8, {
+ senderFilters: ['chatgpt'],
+ subjectFilters: ['verification'],
+ maxAttempts: 1,
+ intervalMs: 1,
+ targetEmail: 'expected@example.com',
+ mail2925MatchTargetEmail: true,
+ });
+
+ assert.equal(result.code, '445566');
+ assert.deepEqual(api.getReadAndDeleteCalls(), ['mail-2']);
+});
+
test('handlePollEmail only accepts 2925 mails inside the fixed lookback window', async () => {
const bundle = [
extractFunction('normalizeMinuteTimestamp'),
diff --git a/tests/managed-alias-utils.test.js b/tests/managed-alias-utils.test.js
index 1d07a9b..384522a 100644
--- a/tests/managed-alias-utils.test.js
+++ b/tests/managed-alias-utils.test.js
@@ -26,3 +26,12 @@ test('managed alias utils validate provider email with or without configured bas
assert.equal(api.isManagedAliasEmail('manual@gmail.com', 'gmail', ''), true);
assert.equal(api.isManagedAliasEmail('manual@qq.com', 'gmail', ''), false);
});
+
+test('managed alias utils keep 2925 alias generation behind provide mode only', () => {
+ assert.equal(api.normalizeMail2925Mode('provide'), 'provide');
+ assert.equal(api.normalizeMail2925Mode('receive'), 'receive');
+ assert.equal(api.normalizeMail2925Mode('other'), 'provide');
+ assert.equal(api.usesManagedAliasGeneration('gmail'), true);
+ assert.equal(api.usesManagedAliasGeneration('2925', { mail2925Mode: 'provide' }), true);
+ assert.equal(api.usesManagedAliasGeneration('2925', { mail2925Mode: 'receive' }), false);
+});
diff --git a/tests/sidepanel-mail2925-mode.test.js b/tests/sidepanel-mail2925-mode.test.js
new file mode 100644
index 0000000..85de056
--- /dev/null
+++ b/tests/sidepanel-mail2925-mode.test.js
@@ -0,0 +1,109 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+
+const source = fs.readFileSync('sidepanel/sidepanel.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) {
+ throw new Error(`missing function ${name}`);
+ }
+
+ let parenDepth = 0;
+ let signatureEnded = false;
+ let braceStart = -1;
+ for (let i = start; i < source.length; i += 1) {
+ const ch = source[i];
+ if (ch === '(') {
+ parenDepth += 1;
+ } else if (ch === ')') {
+ parenDepth -= 1;
+ if (parenDepth === 0) {
+ signatureEnded = true;
+ }
+ } else if (ch === '{' && signatureEnded) {
+ braceStart = i;
+ break;
+ }
+ }
+ if (braceStart < 0) {
+ throw new Error(`missing body for function ${name}`);
+ }
+
+ 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);
+}
+
+test('sidepanel html keeps 2925 mode row and standalone pool settings row', () => {
+ const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
+
+ assert.match(html, /id="row-mail-2925-mode"/);
+ assert.match(html, /data-mail2925-mode="provide"/);
+ assert.match(html, /data-mail2925-mode="receive"/);
+ assert.match(html, /id="row-mail2925-pool-settings"/);
+});
+
+test('sidepanel only treats 2925 as generated alias provider in provide mode', () => {
+ const bundle = [
+ extractFunction('isManagedAliasProvider'),
+ extractFunction('usesGeneratedAliasMailProvider'),
+ ].join('\n');
+
+ const api = new Function(`
+const GMAIL_PROVIDER = 'gmail';
+const MAIL_2925_MODE_PROVIDE = 'provide';
+const MAIL_2925_MODE_RECEIVE = 'receive';
+const DEFAULT_MAIL_2925_MODE = MAIL_2925_MODE_PROVIDE;
+const selectMailProvider = { value: '2925' };
+
+function normalizeMail2925Mode(value = '') {
+ return String(value || '').trim().toLowerCase() === MAIL_2925_MODE_RECEIVE
+ ? MAIL_2925_MODE_RECEIVE
+ : DEFAULT_MAIL_2925_MODE;
+}
+
+function getSelectedMail2925Mode() {
+ return MAIL_2925_MODE_PROVIDE;
+}
+
+function getManagedAliasUtils() {
+ return {
+ usesManagedAliasGeneration(provider, options = {}) {
+ return String(provider || '').trim().toLowerCase() === 'gmail'
+ || (String(provider || '').trim().toLowerCase() === '2925'
+ && normalizeMail2925Mode(options.mail2925Mode) === MAIL_2925_MODE_PROVIDE);
+ },
+ };
+}
+
+${bundle}
+
+return {
+ isManagedAliasProvider,
+ usesGeneratedAliasMailProvider,
+};
+`)();
+
+ assert.equal(api.isManagedAliasProvider('2925', 'provide'), true);
+ assert.equal(api.isManagedAliasProvider('2925', 'receive'), false);
+ assert.equal(api.usesGeneratedAliasMailProvider('2925', 'provide'), true);
+ assert.equal(api.usesGeneratedAliasMailProvider('2925', 'receive'), false);
+ assert.equal(api.usesGeneratedAliasMailProvider('gmail', 'receive'), true);
+});
diff --git a/tests/verification-flow-polling.test.js b/tests/verification-flow-polling.test.js
index 77ce17b..ea37f4c 100644
--- a/tests/verification-flow-polling.test.js
+++ b/tests/verification-flow-polling.test.js
@@ -43,6 +43,49 @@ test('verification flow keeps 2925 polling cadence in the default payload', () =
assert.equal(step8Payload.intervalMs, 15000);
});
+test('verification flow only enables 2925 target email matching in receive mode', () => {
+ 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 () => ({}),
+ sendToMailContentScriptResilient: async () => ({}),
+ setState: async () => {},
+ setStepStatus: async () => {},
+ sleepWithStop: async () => {},
+ throwIfStopped: () => {},
+ VERIFICATION_POLL_MAX_ROUNDS: 5,
+ });
+
+ const providePayload = helpers.getVerificationPollPayload(4, {
+ email: 'user@example.com',
+ mailProvider: '2925',
+ mail2925Mode: 'provide',
+ });
+ const receivePayload = helpers.getVerificationPollPayload(4, {
+ email: 'user@example.com',
+ mailProvider: '2925',
+ mail2925Mode: 'receive',
+ });
+
+ assert.equal(providePayload.mail2925MatchTargetEmail, false);
+ assert.equal(receivePayload.mail2925MatchTargetEmail, true);
+});
+
test('verification flow runs beforeSubmit hook before filling the code', async () => {
const events = [];
diff --git a/项目完整链路说明.md b/项目完整链路说明.md
index 88385c0..59245dc 100644
--- a/项目完整链路说明.md
+++ b/项目完整链路说明.md
@@ -336,7 +336,8 @@
- 自动重新发送验证码次数现在使用 sidepanel 里的单一“验证码重发”配置;普通邮箱仍按 25 秒间隔节流,Hotmail / 2925 不走这个 25 秒间隔。Step 4 若启用先请求新验证码,会先消耗一次当前步骤的自动重发次数。
- 验证码提交重试上限当前为 15 次;页面明确拒绝验证码时,会在上限内继续拉取新验证码并重提。
- `2925` 内容脚本会把每一封实际打开检测的邮件立即删除;同一验证码步骤启动后,试过的验证码会按“步骤 ID + 启动时间”隔离缓存,不会在本次步骤里重复提交;如果再次遇到相同验证码,对应邮件也会在读取后立即删除,避免后续反复打开。
-- `2925` 当前不再对邮件里的收件邮箱做比对,只要邮件内容命中 ChatGPT / OpenAI 验证码过滤条件,就会尝试该邮件。
+- `2925` 在 provide 模式下仍保持宽松匹配:只要邮件内容命中 ChatGPT / OpenAI 验证码过滤条件,就会尝试该邮件。
+- `2925` 在 receive 模式下会恢复“弱目标邮箱匹配”:只有当邮件里显式出现了其他收件邮箱时才会跳过;如果邮件里没有明确写出邮箱,仍允许继续尝试该验证码。
- 当验证码最终提交成功后,后台会异步向 2925 邮箱页发送 `DELETE_ALL_EMAILS`,执行“全选 + 删除”清理剩余邮件,不阻塞主流程。
- 如果 `2925` 邮箱页在轮询期间出现“子邮箱已达上限邮箱”,后台会记录当前时间,把当前 2925 账号禁用 24 小时,自动切到下一个可用账号并完成登录,然后直接报错结束当前尝试;如果 Auto 开启了自动重试,现有控制器会按原逻辑进入下一次尝试。
@@ -409,7 +410,8 @@
补充:
-- 对 `2925` 而言,Step 8 不再依赖验证码页显示邮箱做收件匹配,而是直接测试所有命中 ChatGPT / OpenAI 过滤条件的邮件。
+- 对 `2925 provide` 而言,Step 8 仍不依赖验证码页显示邮箱做收件匹配,而是直接测试所有命中 ChatGPT / OpenAI 过滤条件的邮件。
+- 对 `2925 receive` 而言,Step 8 会把当前目标注册邮箱一并传给 2925 内容脚本;只有当邮件里显式写出了其他邮箱时才会跳过,从而在不破坏历史兼容性的前提下,尽量降低误收验证码的概率。
### Step 9
@@ -452,25 +454,26 @@
### 7.1 2026-04-17 补充:Gmail / 2925 统一别名邮箱链路
-本轮将 Gmail 与 2925 的注册邮箱逻辑统一收敛为“共享别名邮箱链路”:
+本轮将 Gmail 与 `2925 + provide` 的注册邮箱逻辑统一收敛为“共享别名邮箱链路”:
- 两者都先填写“基邮箱”
- Gmail:`name@gmail.com`
- - 2925:`name@2925.com`
+ - 2925(仅 provide 模式):`name@2925.com`
- 两者都允许两种入口:
- 点击侧边栏按钮自动生成完整注册邮箱
- 直接在“注册邮箱”输入框中手动填写完整邮箱
+- 当 `mailProvider = 2925` 且 `mail2925Mode = receive` 时,不再走别名基邮箱链路,而是回退到普通“邮箱生成 / 手动填写注册邮箱”路线;2925 仅负责后续收信与登录态管理
当前行为约定:
-1. sidepanel 展示“别名基邮箱”输入框,并根据当前 provider 显示对应文案
+1. sidepanel 会根据当前 provider 与 `mail2925Mode` 决定是否展示“别名基邮箱”输入框
2. 点击 `获取 / 生成` 时:
- Gmail 生成 `name+tag@gmail.com`
- - 2925 生成 `name123456@2925.com`
+ - 2925 仅在 provide 模式下生成 `name123456@2925.com`
3. Step 2 / Step 3 进入注册流程前,会先判断当前 `state.email`
- 如果已经是与当前基邮箱兼容的完整邮箱,则直接复用
- - 如果为空或不兼容,则按当前 provider 重新生成
-4. 保存或执行 Step 3 时,如果手动填写的完整邮箱与当前 Gmail / 2925 基邮箱不兼容,sidepanel 会直接拦截
+ - 如果为空或不兼容,则按当前 provider / mode 重新生成
+4. 保存或执行 Step 3 时,如果手动填写的完整邮箱与当前 Gmail / 2925 provide 基邮箱不兼容,sidepanel 会直接拦截;`2925 receive` 不参与这条兼容性约束
5. auto-run fresh attempt reset 时,会保留:
- `gmailBaseEmail`
- `mail2925BaseEmail`
@@ -554,13 +557,14 @@
链路:
1. 用户在 sidepanel 的 2925 账号池中保存 `email / password`
-2. 只有当 sidepanel 中的 `mail2925UseAccountPool` 开关开启时,别名基邮箱才会优先取当前账号池选中的 2925 账号邮箱;关闭时会回退到原来的手填 `mail2925BaseEmail`
-3. 手动点击 `登录` 或自动流程进入 Step 4 前,后台会先打开当前 2925 邮箱页:如果仍停留在收件箱则直接复用;如果跳到登录页,则仅在号池模式开启时才自动登录,关闭号池时直接调用现有停止逻辑结束流程
-4. 一旦轮询期间出现“子邮箱已达上限邮箱”,后台会先判断是否启用了号池模式:若已启用且还有下一个可用账号,则把当前账号禁用 24 小时并自动切到下一个账号重新登录;若未启用,则直接调用现有停止逻辑结束流程
-5. 如果登录提交后 20 秒内仍未进入收件箱,且当前正处于自动运行中,则后台会直接复用现有 `requestStop()` 停止链路,把整个自动流程停成和用户手动点击“停止”一致的状态;这类情况常见于图片验证、行为验证或其他阻断登录的中间页
-6. 如果没有下一个可用账号,或当前未启用号池模式,则不会继续消耗自动重试次数,而是直接复用现有 `requestStop()` 停止链路,把整个自动流程停成和用户手动点击“停止”一致的状态
-7. sidepanel 中 2925 账号池的新增表单也走与 Hotmail 相同的共享交互:默认收起,头部按钮切换“添加账号 / 取消添加”,操作行右侧提供“批量导入”,保存成功后自动收起并清空。
-8. 当 2925 号池模式开启时,当前选中的号池邮箱会同步回写到同一个 `mail2925BaseEmail` 字段;因此用户切换号池账号后,即使再次关闭号池模式,也会直接沿用刚才选中的邮箱作为手动基邮箱,无需重新输入。
+2. sidepanel 中会单独展示 `提供邮箱 / 接收邮箱` 模式切换,以及独立的 `2925 号池` 开关 / 当前账号下拉框;这样即使切到 receive 模式,账号池设置也不会被别名基邮箱行一起隐藏
+3. 只有当 sidepanel 中的 `mail2925UseAccountPool` 开关开启时,provide 模式下的别名基邮箱才会优先取当前账号池选中的 2925 账号邮箱;关闭时会回退到原来的手填 `mail2925BaseEmail`
+4. 手动点击 `登录` 或自动流程进入 Step 4 前,后台会先打开当前 2925 邮箱页:如果仍停留在收件箱则直接复用;如果跳到登录页,则仅在号池模式开启时才自动登录,关闭号池时直接调用现有停止逻辑结束流程
+5. 一旦轮询期间出现“子邮箱已达上限邮箱”,后台会先判断是否启用了号池模式:若已启用且还有下一个可用账号,则把当前账号禁用 24 小时并自动切到下一个账号重新登录;若未启用,则直接调用现有停止逻辑结束流程
+6. 如果登录提交后 20 秒内仍未进入收件箱,且当前正处于自动运行中,则后台会直接复用现有 `requestStop()` 停止链路,把整个自动流程停成和用户手动点击“停止”一致的状态;这类情况常见于图片验证、行为验证或其他阻断登录的中间页
+7. 如果没有下一个可用账号,或当前未启用号池模式,则不会继续消耗自动重试次数,而是直接复用现有 `requestStop()` 停止链路,把整个自动流程停成和用户手动点击“停止”一致的状态
+8. sidepanel 中 2925 账号池的新增表单也走与 Hotmail 相同的共享交互:默认收起,头部按钮切换“添加账号 / 取消添加”,操作行右侧提供“批量导入”,保存成功后自动收起并清空。
+9. 当 2925 号池模式开启时,当前选中的号池邮箱会同步回写到同一个 `mail2925BaseEmail` 字段;因此用户切换号池账号后,即使再次关闭号池模式,也会直接沿用刚才选中的邮箱作为手动基邮箱,无需重新输入。
### 7.5 iCloud
diff --git a/项目文件结构说明.md b/项目文件结构说明.md
index 2de56ea..7974da3 100644
--- a/项目文件结构说明.md
+++ b/项目文件结构说明.md
@@ -27,7 +27,7 @@
- `icloud-utils.js`:iCloud 隐私邮箱相关的纯工具函数,负责 host、别名列表、保留状态、已用状态等归一化。
- `luckmail-utils.js`:LuckMail 相关的纯工具函数,负责邮箱购买记录、标签、邮件 cursor、验证码匹配等归一化。
- `mail2925-utils.js`:2925 账号池相关的纯工具函数,负责账号归一化、冷却期判断、可用账号挑选、批量导入解析与列表更新。
-- `managed-alias-utils.js`:共享 Gmail / 2925 的别名邮箱规则,负责解析基邮箱、校验“当前完整注册邮箱”是否仍与基邮箱兼容、生成 Gmail `+tag` 与 2925 随机后缀邮箱,并输出 sidepanel 可复用的 UI 文案。
+- `managed-alias-utils.js`:共享 Gmail / 2925 的别名邮箱规则,负责解析基邮箱、校验“当前完整注册邮箱”是否仍与基邮箱兼容、生成 Gmail `+tag` 与 2925 随机后缀邮箱,并输出 sidepanel 可复用的 UI 文案;当前还统一承接 `mail2925Mode` 的归一化与“2925 仅在 provide 模式下参与别名生成”的共享判定。
- `manifest.json`:Chrome 扩展清单,声明权限、背景脚本、侧边栏、内容脚本与规则集。
- `microsoft-email.js`:Microsoft Graph / Outlook 邮件读取辅助模块,负责刷新令牌换 token、邮箱夹轮询和验证码提取。
- `package.json`:仓库最小 Node 包配置,目前主要提供测试脚本定义。
@@ -44,15 +44,15 @@
- `background/account-run-history.js`:邮箱记录模块,负责以邮箱为主键维护最新记录(同邮箱后续状态覆盖),在步骤 2 设定邮箱后支持先写入“停止/未完成”占位状态,统一归一化成功/失败/停止三态并落地到 `chrome.storage.local`,支持清理记录,并在启用独立本地同步配置后把完整快照同步到本地 helper。
- `background/auto-run-controller.js`:自动运行主控制器,封装多轮执行、重试、轮次摘要、线程间隔与倒计时恢复逻辑;当前自动流程会绑定 `autoRunSessionId`,手动停止后旧的倒计时计划、旧重试链路和旧恢复入口不会再复活已失效的自动运行;fresh-attempt reset 时会额外保留 `gmailBaseEmail`、`mail2925BaseEmail` 与当前 2925 账号选择,避免自动流程重置后丢失别名基邮箱与 2925 切号上下文。
- `background/contribution-oauth.js`:贡献模式的公开 OAuth 流程模块,负责调用 `apikey.qzz.io` 的公开贡献接口、保存贡献会话运行态、在主 10 步流程里为步骤 7 提供贡献登录地址、在步骤 9/10 衔接 callback 捕获与兼容提交 `/oauth/api/submit-callback`,并把真实服务端状态映射回 sidepanel 运行态。
-- `background/generated-email-helpers.js`:生成邮箱辅助层,除了 Duck / Cloudflare / iCloud / Cloudflare Temp Email,也统一承接 Gmail / 2925 的别名邮箱生成入口;当 provider 为 2925 时,会先确保当前账号池里已有可用账号,再基于该账号生成别名邮箱。
+- `background/generated-email-helpers.js`:生成邮箱辅助层,除了 Duck / Cloudflare / iCloud / Cloudflare Temp Email,也统一承接 Gmail / 2925 的别名邮箱生成入口;当 provider 为 2925 且 `mail2925Mode = provide` 时,会先确保当前账号池里已有可用账号,再基于该账号生成别名邮箱;若 `mail2925Mode = receive`,则会回退到普通邮箱生成链路。
- `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层;当前额外承接 `add-phone / 手机号页` 这类认证 fatal 错误的共享判定。
- `background/mail-2925-session.js`:2925 会话模块,负责 2925 账号池持久化、当前账号切换、cookie 清理登出、自动登录、命中“子邮箱已达上限邮箱”后的 24 小时禁用与自动切号。
- `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息;当前额外接入 2925 账号池的新增、导入、切换、登录、禁用与删除消息。
- `background/navigation-utils.js`:导航与 URL 判断工具层,负责 callback、入口页、CPA / SUB2API 地址、步骤跳转相关判断。
- `background/panel-bridge.js`:CPA / SUB2API 面板桥接层,封装 OAuth 地址获取所需的页面打开、脚本注入和通信。
-- `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、等待密码页以及解析当前流程所用邮箱;在 Gmail / 2925 模式下会优先复用已经存在且仍兼容的完整注册邮箱,只有不兼容或为空时才重新生成;当 provider 为 2925 时,会先确保账号池中已选中可用账号。
+- `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、等待密码页以及解析当前流程所用邮箱;在 Gmail 与 `2925 + provide` 模式下会优先复用已经存在且仍兼容的完整注册邮箱,只有不兼容或为空时才重新生成;当 provider 为 2925 且启用了 provide 模式下的号池时,会先确保账号池中已选中可用账号。
- `background/tab-runtime.js`:标签页与内容脚本运行时基础设施,封装标签注册、冲突清理、消息超时、注入重试与队列;当前等待标签完成、注入后的短暂延迟和内容脚本重试等待都已做 Stop 感知,避免用户停止后后台还继续等待并恢复执行。
-- `background/verification-flow.js`:注册/登录验证码共享流程层,封装重发、轮询、提交、失败回退、自定义邮箱跳过、共享验证码自动重发次数配置以及 2925 长轮询参数;当前验证码提交重试上限为 15 次,2925 每次重发验证码之间都会固定跑完一轮 15 次邮箱刷新轮询;若 2925 轮询命中“子邮箱已达上限邮箱”,会转入 2925 会话模块执行“记录时间、禁用 24 小时、切下一个账号并重新登录”,然后直接结束当前尝试。
+- `background/verification-flow.js`:注册/登录验证码共享流程层,封装重发、轮询、提交、失败回退、自定义邮箱跳过、共享验证码自动重发次数配置以及 2925 长轮询参数;当前验证码提交重试上限为 15 次,2925 每次重发验证码之间都会固定跑完一轮 15 次邮箱刷新轮询;若 `mail2925Mode = receive`,会额外把目标注册邮箱传给 2925 内容脚本做弱匹配;若 2925 轮询命中“子邮箱已达上限邮箱”,会转入 2925 会话模块执行“记录时间、禁用 24 小时、切下一个账号并重新登录”,然后直接结束当前尝试。
## `background/steps/`
@@ -77,7 +77,7 @@
- `content/icloud-mail.js`:iCloud 邮箱页面脚本,负责在 iCloud Mail 页面中读取邮件详情和验证码。
- `content/inbucket-mail.js`:Inbucket 邮箱轮询脚本,负责在 Inbucket 页面中读取、删除验证码邮件。
- `content/mail-163.js`:163 / 163 VIP 邮箱轮询脚本,负责网页邮箱验证码读取和邮件清理。
-- `content/mail-2925.js`:2925 邮箱页面脚本,负责 2925 邮箱自动登录态确认、收件轮询、按步骤会话隔离“已试验证码”、在每次重发验证码之间执行一轮最多 15 次的邮箱刷新轮询、命中邮件后立即删当前邮件,以及在成功后配合后台执行整箱清理;若页面出现“子邮箱已达上限邮箱”提示,会立即上报后台进入切号链路。
+- `content/mail-2925.js`:2925 邮箱页面脚本,负责 2925 邮箱自动登录态确认、收件轮询、按步骤会话隔离“已试验证码”、在每次重发验证码之间执行一轮最多 15 次的邮箱刷新轮询、命中邮件后立即删当前邮件,以及在成功后配合后台执行整箱清理;若页面出现“子邮箱已达上限邮箱”提示,会立即上报后台进入切号链路;当后台显式开启 `mail2925MatchTargetEmail` 时,会对邮件里显式出现的目标邮箱做弱匹配,避免 `receive` 模式误捞别人的验证码。
- `content/qq-mail.js`:QQ 邮箱轮询脚本,负责网页邮箱验证码读取。
- `content/signup-page.js`:注册、登录、授权主内容脚本,负责 OpenAI / ChatGPT 页面上的步骤执行;其中 Step 2 / 3 的延迟点击与延迟提交在真正触发前会先检查 Stop 状态,避免停止后页面继续自动点击。
- `content/sub2api-panel.js`:SUB2API 后台内容脚本,负责获取 OAuth 地址和提交 localhost 回调;当前承接步骤 10。
@@ -121,8 +121,8 @@
- `sidepanel/contribution-content-update-service.js`:侧边栏贡献内容更新服务,负责拉取 `https://apikey.qzz.io/api/content-summary`、缓存公开公告/教程摘要,并输出可用于提示展示的 `promptVersion` 与最新更新时间。
- `sidepanel/contribution-mode.js`:侧边栏贡献模式管理器,负责顶部“贡献”按钮、确认弹窗、贡献模式显隐、复用主自动流程启动、侧栏内贡献状态轮询、上传页跳转,以及贡献模式下对来源选择、配置入口、记录入口和敏感配置行的禁用与隐藏。
- `sidepanel/sidepanel.css`:侧边栏样式文件;当前额外提供 Hotmail / 2925 共用的号池表单容器、操作按钮行与统一的收起态列表高度样式。
-- `sidepanel/sidepanel.html`:侧边栏页面结构;当前步骤列表已改为动态容器,日志区提供“记录”按钮并挂接邮箱记录覆盖层,顶部新增“贡献”按钮并在设置卡片中新增贡献模式主面板;贡献按钮下方额外挂接一个可关闭的轻提示气泡,用于提示公开公告 / 使用教程有更新;贡献面板内展示 `OAUTH / 回调 / 总状态` 三块真实运行态信息,同时把“本地同步”与“验证码重发”拆成独立行以避免特殊模式隐藏时互相影响;页面继续加载 `managed-alias-utils.js`,并把旧的“邮箱前缀”字段语义改为“别名基邮箱”;当 provider 为 2925 时,别名基邮箱行右侧会显示“号池”开关,开启后输入框切换为号池邮箱下拉选择,并显示 2925 账号池管理区;Hotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器。
-- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、独立本地同步配置、共享验证码自动重发次数配置与广播接收,并装配 Hotmail / 2925 / iCloud / LuckMail / 贡献模式 / 邮箱记录面板 / 贡献内容更新服务;当前贡献模式的“开始贡献”会直接复用主自动流程启动逻辑,而独立 manager 负责贡献运行态展示与轮询,同时把 Gmail / 2925 的基邮箱输入、完整注册邮箱输入、自动生成按钮与兼容性校验统一接到共享别名逻辑上;当 2925 号池开关开启时,当前账号变化会同步映射到 2925 基邮箱下拉选择,关闭时则回退到原来的手填基邮箱路线;Hotmail / 2925 的新增表单显隐则统一接到 `sidepanel/account-pool-ui.js` 这一层共享 helper;侧边栏初始化与点击“自动”前会刷新一次贡献站公开内容摘要,并按本地关闭版本决定是否展示轻提示。
+- `sidepanel/sidepanel.html`:侧边栏页面结构;当前步骤列表已改为动态容器,日志区提供“记录”按钮并挂接邮箱记录覆盖层,顶部新增“贡献”按钮并在设置卡片中新增贡献模式主面板;贡献按钮下方额外挂接一个可关闭的轻提示气泡,用于提示公开公告 / 使用教程有更新;贡献面板内展示 `OAUTH / 回调 / 总状态` 三块真实运行态信息,同时把“本地同步”与“验证码重发”拆成独立行以避免特殊模式隐藏时互相影响;页面继续加载 `managed-alias-utils.js`,并把旧的“邮箱前缀”字段语义改为“别名基邮箱”;当 provider 为 2925 时,会额外显示 `提供邮箱 / 接收邮箱` 模式切换,并把“2925 号池”从别名基邮箱行拆成独立配置行,避免 receive 模式把账号池开关一起隐藏;Hotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器。
+- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、独立本地同步配置、共享验证码自动重发次数配置与广播接收,并装配 Hotmail / 2925 / iCloud / LuckMail / 贡献模式 / 邮箱记录面板 / 贡献内容更新服务;当前贡献模式的“开始贡献”会直接复用主自动流程启动逻辑,而独立 manager 负责贡献运行态展示与轮询,同时把 Gmail / 2925 的基邮箱输入、完整注册邮箱输入、自动生成按钮与兼容性校验统一接到共享别名逻辑上;当 provider 为 2925 时,会根据 `mail2925Mode` 决定是否启用别名基邮箱链路,而 2925 号池开关与当前账号选择则独立显示并同时服务于 provide / receive 两种模式;Hotmail / 2925 的新增表单显隐统一接到 `sidepanel/account-pool-ui.js` 这一层共享 helper;侧边栏初始化与点击“自动”前会刷新一次贡献站公开内容摘要,并按本地关闭版本决定是否展示轻提示。
- `sidepanel/update-service.js`:侧边栏更新检查服务,负责 GitHub Releases 查询、`Pro` / `v` 双版本族排序、缓存读取与版本展示。
## `tests/`
@@ -182,6 +182,7 @@
- `tests/sidepanel-icloud-provider.test.js`:测试侧边栏 iCloud 登录地址解析逻辑。
- `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/signup-entry-diagnostics.test.js`:测试注册入口诊断快照输出。
- `tests/signup-page-tab-cleanup.test.js`:测试注册页来源标签的冲突清理逻辑。
- `tests/step-definitions-module.test.js`:测试共享步骤定义模块及侧边栏脚本加载顺序。
From cf932077bc6c2c55b3f8f529b0cf5b718912ad84 Mon Sep 17 00:00:00 2001
From: QLHazyCoder <2825305047@qq.com>
Date: Tue, 21 Apr 2026 23:21:28 +0800
Subject: [PATCH 10/23] =?UTF-8?q?=E8=A1=A5=E5=85=85=E6=96=87=E4=BB=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
项目完整链路说明.md | 52 +++++++++++++++++++++++++++++++++++++++
项目开发规范(AI协作).md | 2 ++
2 files changed, 54 insertions(+)
diff --git a/项目完整链路说明.md b/项目完整链路说明.md
index 59245dc..c7c8c33 100644
--- a/项目完整链路说明.md
+++ b/项目完整链路说明.md
@@ -566,6 +566,58 @@
8. sidepanel 中 2925 账号池的新增表单也走与 Hotmail 相同的共享交互:默认收起,头部按钮切换“添加账号 / 取消添加”,操作行右侧提供“批量导入”,保存成功后自动收起并清空。
9. 当 2925 号池模式开启时,当前选中的号池邮箱会同步回写到同一个 `mail2925BaseEmail` 字段;因此用户切换号池账号后,即使再次关闭号池模式,也会直接沿用刚才选中的邮箱作为手动基邮箱,无需重新输入。
+### 7.4.1 2925 双模式维护约定
+
+这是后续维护 `2925` 时最容易被重新打散的一段链路,建议按下面的职责边界理解:
+
+- UI 层:
+ - `sidepanel/sidepanel.html`
+ - 负责展示 `提供邮箱 / 接收邮箱`
+ - 负责展示独立的 `2925 号池` 配置行
+ - 不负责决定“2925 是否参与别名邮箱生成”
+ - `sidepanel/sidepanel.js`
+ - 只负责把当前 `mail2925Mode`、号池开关、当前账号选择同步到 state / runtime message
+ - 只负责用共享规则决定当前文案、显隐和前端校验
+ - 不应再次复制一套“provide / receive 对应什么行为”的业务判断
+
+- 共享规则层:
+ - `managed-alias-utils.js`
+ - 负责 `mail2925Mode` 归一化
+ - 负责判断 `2925` 当前是否属于“别名邮箱 provider”
+ - 负责保证 `2925` 只有在 `provide` 模式下才进入共享别名邮箱链路
+ - 如果未来要新增第三种模式,应该先改这里,再改 sidepanel / background 接线
+
+- 注册邮箱生成层:
+ - `background/generated-email-helpers.js`
+ - 只负责根据当前 provider / mode 选择“共享别名邮箱”还是“普通邮箱生成器”
+ - 不负责定义 `provide / receive` 的语义本身
+ - `background/signup-flow-helpers.js`
+ - 只负责真正提交前的“复用已有邮箱 / 重新生成邮箱”
+ - 这里看到的 `isGeneratedAliasProvider(state)` 已经包含 `mail2925Mode` 语义,不应该再手写 `state.mail2925Mode === 'provide'`
+
+- 收信与验证码层:
+ - `background/verification-flow.js`
+ - 负责决定是否给 2925 内容脚本下发 `mail2925MatchTargetEmail`
+ - 当前约定:仅 `receive` 模式开启弱目标邮箱匹配
+ - `content/mail-2925.js`
+ - 负责真正执行“弱目标邮箱匹配”
+ - 当前约定:只有邮件里显式出现了其他邮箱才跳过;若邮件里没有写邮箱,仍允许继续尝试
+ - 这样做的目的,是在不破坏历史兼容性的前提下,降低 receive 模式误收验证码的概率
+
+- 账号池与登录态层:
+ - `background/mail-2925-session.js`
+ - 不关心 `provide / receive` 哪个负责生成注册邮箱
+ - 只关心当前是否允许自动登录、是否需要切号、是否命中上限、是否需要停止自动流程
+ - 因此后续如果只改别名生成语义,不应把这层重新卷入 provider 判定
+
+后续如果再改 `2925`,建议最少按下面顺序自检:
+
+1. `managed-alias-utils.js` 是否仍是 `provide / receive` 的唯一共享语义来源
+2. `sidepanel/sidepanel.js` 是否只做接线,没有复制业务规则
+3. `background/generated-email-helpers.js` / `background/signup-flow-helpers.js` 是否仍然只做调度,不重复写模式语义
+4. `background/verification-flow.js` 与 `content/mail-2925.js` 的“弱目标邮箱匹配”是否保持同步
+5. `项目文件结构说明.md` 与当前文件是否已同步更新
+
### 7.5 iCloud
组成:
diff --git a/项目开发规范(AI协作).md b/项目开发规范(AI协作).md
index 5b99d0e..8b7ef6a 100644
--- a/项目开发规范(AI协作).md
+++ b/项目开发规范(AI协作).md
@@ -93,10 +93,12 @@
当前约定:
- Gmail / 2925 的基邮箱解析、兼容性判断、别名生成、UI 文案优先收敛到 `managed-alias-utils.js`
+- `2925` 是否参与“共享别名邮箱链路”必须由共享层统一判断;当前只有 `mail2925Mode = provide` 才允许把 `2925` 视为别名邮箱 provider,`receive` 不能在 sidepanel / step / provider 分支里各自偷写一套判断
- `background/generated-email-helpers.js` 只负责调度,不应再次复制 Gmail / 2925 规则
- `background/signup-flow-helpers.js` 只负责“复用已有邮箱还是重新生成”的流程决策
- `sidepanel/sidepanel.js` 只负责 UI 接线、校验触发和状态同步
- Hotmail / 2925 账号池这类跨 provider 的 sidepanel 表单显隐、头部按钮文案切换与共用操作行,应优先收敛到共享 UI helper(当前为 `sidepanel/account-pool-ui.js`),不要在各自 manager 中复制一套近似状态机
+- 如果 `2925` 的某个运行态模式会让“别名基邮箱”和“账号池 / 当前账号选择”出现不同显隐规则,必须优先拆行或拆成独立配置块,不能把账号池开关绑死在别名基邮箱那一行里
### 3.3 新增配置项
From 3d69413b05166ed86759b880ad728a8832bbb292 Mon Sep 17 00:00:00 2001
From: QLHazyCoder <2825305047@qq.com>
Date: Tue, 21 Apr 2026 23:26:46 +0800
Subject: [PATCH 11/23] chore(release): bump version to Pro5.6
---
manifest.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/manifest.json b/manifest.json
index 255378b..5b28f6d 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,8 +1,8 @@
{
"manifest_version": 3,
"name": "多页面自动化",
- "version": "5.5",
- "version_name": "Pro5.5",
+ "version": "5.6",
+ "version_name": "Pro5.6",
"description": "用于自动执行多步骤 OAuth 注册流程",
"permissions": [
"sidePanel",
From c35127c5b7e39acd2641c35c3a9fa6a535260f07 Mon Sep 17 00:00:00 2001
From: QLHazyCoder <2825305047@qq.com>
Date: Wed, 22 Apr 2026 02:10:07 +0800
Subject: [PATCH 12/23] fix: remove hardcoded max run count and adjust fallback
risk warning logic
---
background.js | 2 +-
sidepanel/sidepanel.html | 2 +-
sidepanel/sidepanel.js | 15 +--
tests/run-count-unlimited.test.js | 93 ++++++++++++++++++
tests/sidepanel-auto-run-risk-warning.test.js | 96 +++++++++++++++++++
5 files changed, 196 insertions(+), 12 deletions(-)
create mode 100644 tests/run-count-unlimited.test.js
create mode 100644 tests/sidepanel-auto-run-risk-warning.test.js
diff --git a/background.js b/background.js
index eb23563..7297909 100644
--- a/background.js
+++ b/background.js
@@ -450,7 +450,7 @@ function normalizeRunCount(value) {
if (!Number.isFinite(numeric)) {
return 1;
}
- return Math.min(50, Math.max(1, Math.floor(numeric)));
+ return Math.max(1, Math.floor(numeric));
}
function normalizeAutoRunTimerKind(value = '') {
diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html
index c8f4b05..6b75d35 100644
--- a/sidepanel/sidepanel.html
+++ b/sidepanel/sidepanel.html
@@ -36,7 +36,7 @@
-
+
//
@@ -192,19 +195,19 @@ function isLocalhostOAuthCallbackUrl(rawUrl) {
function getStatusBadgeSelectors() {
return [
- '#root > div > div > div > main > div > div > div > div > div:nth-child(1) > div > div.OAuthPage-module__cardContent___1sXLA > div.status-badge',
- '#root .OAuthPage-module__cardContent___1sXLA > .status-badge',
- '.OAuthPage-module__cardContent___1sXLA > .status-badge',
+ '#root .OAuthPage-module__cardContent___1sXLA .status-badge',
+ '[class*="cardContent"] .status-badge',
'.status-badge',
];
}
function getStatusBadgeEntries() {
+ const searchRoot = findCodexOAuthCard() || document;
const seen = new Set();
const entries = [];
for (const selector of getStatusBadgeSelectors()) {
- const candidates = document.querySelectorAll(selector);
+ const candidates = searchRoot.querySelectorAll(selector);
for (const candidate of candidates) {
if (seen.has(candidate)) continue;
seen.add(candidate);
@@ -238,21 +241,63 @@ function normalizeStep9StatusText(statusText) {
}
function isOAuthCallbackTimeoutFailure(statusText) {
- return /认证失败:\s*(?:Timeout waiting for OAuth callback|timeout of \d+ms exceeded)/i.test(statusText || '');
+ return /(?:认证失败\s*[::]?\s*)?(?:Timeout waiting for OAuth callback|timeout of \d+ms exceeded|OAuth flow timed out)/i.test(statusText || '');
+}
+
+function getStep10StatusBadgeLocation(element) {
+ if (element?.closest?.('[class*="callbackSection"]')) {
+ return 'callback';
+ }
+ if (element?.closest?.('[class*="cardContent"]')) {
+ return 'main';
+ }
+ return 'page';
+}
+
+function isStep10CallbackSubmittedStatus(statusText) {
+ const text = normalizeStep9StatusText(statusText);
+ return /回调\s*url\s*已提交.*等待认证中/i.test(text)
+ || /callback\s*url\s*submitted.*waiting/i.test(text);
+}
+
+function isStep10CallbackFailureText(statusText) {
+ const text = normalizeStep9StatusText(statusText);
+ if (!text) return false;
+ return /(?:回调\s*url\s*提交失败|回调url提交失败|提交回调失败)\s*[::,,]?\s*/i.test(text)
+ || /请更新\s*cli\s*proxy\s*api\s*或检查连接/i.test(text);
+}
+
+function isStep10MainWaitingStatus(statusText) {
+ const text = normalizeStep9StatusText(statusText);
+ return /等待认证中/i.test(text);
+}
+
+function isStep10MainFailureText(statusText) {
+ const text = normalizeStep9StatusText(statusText);
+ if (!text) return false;
+ if (/^认证失败\s*[::]?\s*/i.test(text)) return true;
+ return /bad request|state code error|failed to exchange authorization code for tokens|failed to save authentication tokens|unknown or expired state|invalid state|state is required|code or error is required|invalid redirect_url|provider does not match state|failed to persist oauth callback|timeout waiting for oauth callback|oauth flow timed out|request failed with status code \d+|timeout of \d+ms exceeded|network error|failed to fetch/i.test(text);
}
function isStep9FailureText(statusText) {
const text = normalizeStep9StatusText(statusText);
if (!text) return false;
if (isOAuthCallbackTimeoutFailure(text)) return true;
+ if (isStep10CallbackFailureText(text)) return true;
+ if (isStep10MainFailureText(text)) return true;
if (typeof isRecoverableStep9AuthFailure === 'function' && isRecoverableStep9AuthFailure(text)) {
return true;
}
- return /回调\s*url\s*提交失败|callback\s*url\s*submit\s*failed|oauth flow is not pending/i.test(text);
+ return /callback\s*url\s*submit\s*failed|oauth flow is not pending/i.test(text);
}
function isStep9SuccessStatus(statusText) {
- return STEP9_SUCCESS_STATUSES.has(normalizeStep9StatusText(statusText));
+ const text = normalizeStep9StatusText(statusText);
+ if (!text) return false;
+ return STEP9_SUCCESS_STATUSES.has(text)
+ || /^认证成功[!!]?$/i.test(text)
+ || /^Authentication successful!?$/i.test(text)
+ || /^Аутентификация успешна!?$/i.test(text);
}
function isStep9SuccessLikeStatus(statusText) {
@@ -340,6 +385,7 @@ function createStep9Entry(candidate, selector) {
return {
element: candidate,
selector,
+ location: getStep10StatusBadgeLocation(candidate),
visible: isVisibleElement(candidate),
text: normalizeStep9StatusText(candidate?.textContent || ''),
className,
@@ -364,36 +410,58 @@ function getStep9PageErrorSelectors() {
}
function getStep9PageErrorEntries() {
+ const cardRoot = findCodexOAuthCard();
+ const searchRoots = [cardRoot, document].filter(Boolean);
const seen = new Set();
const entries = [];
- for (const selector of getStep9PageErrorSelectors()) {
- const candidates = document.querySelectorAll(selector);
- for (const candidate of candidates) {
- if (seen.has(candidate)) continue;
- seen.add(candidate);
- if (!isVisibleElement(candidate)) continue;
+ for (const root of searchRoots) {
+ for (const selector of getStep9PageErrorSelectors()) {
+ const candidates = root.querySelectorAll(selector);
+ for (const candidate of candidates) {
+ if (seen.has(candidate)) continue;
+ seen.add(candidate);
+ if (!isVisibleElement(candidate)) continue;
- const entry = createStep9Entry(candidate, selector);
- if (!isStep9FailureText(entry.text)) continue;
- entries.push(entry);
+ const entry = createStep9Entry(candidate, selector);
+ if (/\bstatus-badge\b/i.test(entry.className)) continue;
+ if (!isStep9FailureText(entry.text)) continue;
+ entries.push(entry);
+ }
}
}
return entries;
}
+function formatStep10StatusSummaryValue(text, emptyText = '无') {
+ return text ? `"${getInlineTextSnippet(text, 80)}"` : emptyText;
+}
+
function buildStep9StatusDiagnostics(entries = [], pageErrorEntries = [], pageSnippet = '') {
const visibleEntries = entries.filter((entry) => entry.visible);
- const successLikeEntries = visibleEntries.filter((entry) => isStep9SuccessLikeStatus(entry.text));
- const exactSuccessEntries = visibleEntries.filter((entry) => isStep9SuccessStatus(entry.text) && !entry.hasErrorVisualSignal);
- const failureEntries = visibleEntries.filter((entry) => isStep9FailureText(entry.text));
+ const callbackEntries = visibleEntries.filter((entry) => entry.location === 'callback');
+ const mainEntries = visibleEntries.filter((entry) => entry.location === 'main');
+ const successLikeEntries = mainEntries.filter((entry) => isStep9SuccessLikeStatus(entry.text));
+ const exactSuccessEntries = mainEntries.filter((entry) => isStep9SuccessStatus(entry.text) && !entry.hasErrorVisualSignal);
+ const callbackSubmittedEntries = callbackEntries.filter((entry) => isStep10CallbackSubmittedStatus(entry.text) && !entry.hasErrorVisualSignal);
+ const callbackFailureEntries = callbackEntries.filter((entry) => isStep10CallbackFailureText(entry.text));
+ const mainWaitingEntries = mainEntries.filter((entry) => isStep10MainWaitingStatus(entry.text) && !entry.hasErrorVisualSignal);
+ const mainFailureEntries = mainEntries.filter((entry) => isStep10MainFailureText(entry.text));
+ const failureEntries = [...callbackFailureEntries, ...mainFailureEntries];
const errorStyledEntries = visibleEntries.filter((entry) => entry.hasErrorVisualSignal);
const allFailureEntries = [...failureEntries, ...pageErrorEntries];
const decisiveFailureEntry = allFailureEntries[0] || null;
- const selectedEntry = decisiveFailureEntry || exactSuccessEntries[0] || visibleEntries[0] || null;
+ const selectedEntry = decisiveFailureEntry
+ || exactSuccessEntries[0]
+ || callbackSubmittedEntries[0]
+ || mainWaitingEntries[0]
+ || visibleEntries[0]
+ || null;
const selectedText = selectedEntry?.text || '';
const visibleSummary = summarizeStatusBadgeEntries(visibleEntries);
+ const callbackSummary = summarizeStatusBadgeEntries(callbackEntries);
+ const mainSummary = summarizeStatusBadgeEntries(mainEntries);
const successLikeSummary = summarizeStatusBadgeEntries(successLikeEntries);
const exactSuccessSummary = summarizeStatusBadgeEntries(exactSuccessEntries);
const failureSummary = summarizeStatusBadgeEntries(failureEntries);
@@ -406,10 +474,20 @@ function buildStep9StatusDiagnostics(entries = [], pageErrorEntries = [], pageSn
selectedText,
exactSuccessText: exactSuccessEntries[0]?.text || '',
failureText: decisiveFailureEntry?.text || '',
+ failureSource: decisiveFailureEntry?.location || (pageErrorEntries.length ? 'page' : ''),
visibleCount: visibleEntries.length,
visibleSummary,
+ callbackSummary,
+ mainSummary,
+ callbackStatusText: callbackEntries[0]?.text || '',
+ callbackSubmittedText: callbackSubmittedEntries[0]?.text || '',
+ callbackFailureText: callbackFailureEntries[0]?.text || '',
+ mainStatusText: mainEntries[0]?.text || '',
+ mainWaitingText: mainWaitingEntries[0]?.text || '',
+ mainFailureText: mainFailureEntries[0]?.text || '',
hasSuccessLikeVisibleBadge: successLikeEntries.length > 0,
hasExactSuccessVisibleBadge: exactSuccessEntries.length > 0,
+ hasCallbackSubmittedBadge: callbackSubmittedEntries.length > 0,
hasFailureVisibleBadge: allFailureEntries.length > 0,
hasErrorStyledVisibleBadge: errorStyledEntries.length > 0,
successLikeSummary,
@@ -422,6 +500,8 @@ function buildStep9StatusDiagnostics(entries = [], pageErrorEntries = [], pageSn
selectedText,
visibleCount: visibleEntries.length,
visibleSummary,
+ callbackSummary,
+ mainSummary,
successLikeSummary,
exactSuccessSummary,
failureSummary,
@@ -429,8 +509,8 @@ function buildStep9StatusDiagnostics(entries = [], pageErrorEntries = [], pageSn
errorStyledSummary,
}),
summary: selectedText
- ? `当前聚焦状态="${getInlineTextSnippet(selectedText, 80)}";可见徽标 ${visibleEntries.length} 个:${visibleSummary}${extraFailureSuffix}${errorStyledSuffix}`
- : `当前未选中任何可见状态徽标;可见徽标 ${visibleEntries.length} 个:${visibleSummary}${extraFailureSuffix}${errorStyledSuffix};页面片段="${getInlineTextSnippet(pageSnippet, 120)}"`,
+ ? `当前聚焦状态=${formatStep10StatusSummaryValue(selectedText)};回调提示=${formatStep10StatusSummaryValue(callbackEntries[0]?.text || '')};主状态=${formatStep10StatusSummaryValue(mainEntries[0]?.text || '')};页面错误=${formatStep10StatusSummaryValue(pageErrorEntries[0]?.text || '')};可见徽标 ${visibleEntries.length} 个:${visibleSummary}${extraFailureSuffix}${errorStyledSuffix}`
+ : `当前未选中任何可见状态;回调提示=${formatStep10StatusSummaryValue(callbackEntries[0]?.text || '')};主状态=${formatStep10StatusSummaryValue(mainEntries[0]?.text || '')};页面错误=${formatStep10StatusSummaryValue(pageErrorEntries[0]?.text || '')};可见徽标 ${visibleEntries.length} 个:${visibleSummary}${extraFailureSuffix}${errorStyledSuffix};页面片段="${getInlineTextSnippet(pageSnippet, 120)}"`,
};
}
@@ -452,11 +532,129 @@ function getStatusBadgeText() {
return diagnostics.selectedText;
}
+function extractStep10FailureDetail(statusText, sourceKind = '') {
+ const text = normalizeStep9StatusText(statusText);
+ if (!text) return '';
+ if (sourceKind === 'callback' || isStep10CallbackFailureText(text)) {
+ return text.replace(/^(?:回调\s*url\s*提交失败|回调url提交失败|提交回调失败)\s*[::,,]?\s*/i, '').trim();
+ }
+ if (sourceKind === 'main' || isStep10MainFailureText(text)) {
+ return text.replace(/^认证失败\s*[::]?\s*/i, '').trim();
+ }
+ return text;
+}
+
+function explainStep10Failure(statusText, sourceKind = 'unknown') {
+ const rawText = normalizeStep9StatusText(statusText);
+ const detail = extractStep10FailureDetail(rawText, sourceKind) || rawText;
+ const phaseLabel = sourceKind === 'callback'
+ ? '回调提交阶段'
+ : sourceKind === 'main'
+ ? '认证结果阶段'
+ : '页面状态阶段';
+
+ const rules = [
+ {
+ code: 'callback_submit_api_unavailable',
+ pattern: /请更新\s*cli\s*proxy\s*api\s*或检查连接/i,
+ message: 'CPA 面板无法把回调提交给后台,通常是 CLI Proxy API 版本过旧、管理接口未启动,或当前面板与后端连接异常。',
+ },
+ {
+ code: 'oauth_state_expired',
+ pattern: /unknown or expired state/i,
+ message: '当前 OAuth 会话在 CPA 中已不存在或已过期,通常是使用了旧回调链接、刷新过新的授权链接后仍提交旧链接,或 CPA 刚重启过。',
+ },
+ {
+ code: 'oauth_not_pending',
+ pattern: /oauth flow is not pending/i,
+ message: '当前 OAuth 会话已经不在等待状态,通常是重复提交、提交过慢,或这轮认证此前已经结束。',
+ },
+ {
+ code: 'callback_state_invalid',
+ pattern: /invalid state|state is required|missing_state/i,
+ message: '回调链接里的 state 缺失或无效,通常是复制了不完整的 localhost 回调链接,或提交了不属于这一轮的旧链接。',
+ },
+ {
+ code: 'callback_missing_result',
+ pattern: /code or error is required/i,
+ message: '回调链接里既没有授权码,也没有错误信息,通常是复制的 localhost 回调链接不完整。',
+ },
+ {
+ code: 'callback_invalid_url',
+ pattern: /invalid redirect_url/i,
+ message: '提交给 CPA 的回调链接格式无法解析,通常是粘贴内容不完整、带了多余字符,或并不是 localhost OAuth 回调地址。',
+ },
+ {
+ code: 'callback_provider_mismatch',
+ pattern: /provider does not match state/i,
+ message: '这条回调不属于当前这次 Codex OAuth,会话与回调来源对不上,通常是混用了其他轮次或其他提供方的回调。',
+ },
+ {
+ code: 'callback_persist_failed',
+ pattern: /failed to persist oauth callback/i,
+ message: 'CPA 已收到回调,但无法把回调结果写入本地缓存文件,通常是认证目录权限、磁盘或运行环境异常。',
+ },
+ {
+ code: 'oauth_bad_request',
+ pattern: /^bad request$/i,
+ message: 'CPA 已收到回调,但 OpenAI OAuth 回调本身返回了错误。常见于用户取消授权、请求过期,或这条回调已经失效。',
+ },
+ {
+ code: 'oauth_state_mismatch',
+ pattern: /state code error/i,
+ message: 'CPA 校验到回调里的 state 与当前 OAuth 会话不一致,通常是步骤 1 已刷新过新的授权链接,但步骤 10 仍提交旧回调。',
+ },
+ {
+ code: 'oauth_code_exchange_failed',
+ pattern: /failed to exchange authorization code for tokens/i,
+ message: 'CPA 已收到授权码,但向 OpenAI 交换令牌失败。常见于 CPA 到 OpenAI 的网络或代理异常,或授权码已过期。',
+ },
+ {
+ code: 'oauth_token_save_failed',
+ pattern: /failed to save authentication tokens/i,
+ message: 'CPA 已完成认证,但保存认证文件失败。常见于认证目录权限、磁盘写入,或 post-auth hook 异常。',
+ },
+ {
+ code: 'oauth_callback_timeout',
+ pattern: /timeout waiting for oauth callback|oauth flow timed out/i,
+ message: 'CPA 长时间没有把这轮 OAuth 流程走完。常见于提交太晚、面板轮询异常,或后端状态没有及时刷新。',
+ },
+ {
+ code: 'oauth_http_timeout',
+ pattern: /timeout of \d+ms exceeded/i,
+ message: 'CPA 面板在请求后台接口时超时,通常是 CLI Proxy API 响应过慢、接口未启动,或网络连接不稳定。',
+ },
+ {
+ code: 'oauth_http_status_error',
+ pattern: /request failed with status code \d+/i,
+ message: 'CPA 面板请求后台接口时收到了异常 HTTP 状态码,通常是接口异常、反向代理配置错误,或当前会话已失效。',
+ },
+ {
+ code: 'oauth_network_error',
+ pattern: /network error|failed to fetch/i,
+ message: 'CPA 面板与后台通信失败,通常是网络不通、管理接口未启动,或浏览器当前连接已断开。',
+ },
+ ];
+
+ const matchedRule = rules.find((rule) => rule.pattern.test(detail) || rule.pattern.test(rawText));
+ const message = matchedRule
+ ? matchedRule.message
+ : `CPA 在${phaseLabel}返回了未归类的失败,请结合面板原文进一步排查。`;
+
+ return {
+ code: matchedRule?.code || 'oauth_unknown_failure',
+ phaseLabel,
+ rawText,
+ detail,
+ userMessage: `CPA 在${phaseLabel}返回失败:${message} 面板原文:${rawText}`,
+ };
+}
+
async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS) {
const start = Date.now();
let lastDiagnosticsSignature = '';
let lastHeartbeatLoggedAt = 0;
- let lastSuccessLikeMismatchSignature = '';
+ let lastCallbackSubmittedSignature = '';
let lastSuccessFailureConflictSignature = '';
while (Date.now() - start < timeout) {
@@ -475,23 +673,18 @@ async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS
console.log(LOG_PREFIX, '[Step 9] still waiting for success badge', diagnostics);
}
- if (diagnostics.hasSuccessLikeVisibleBadge && !diagnostics.hasExactSuccessVisibleBadge) {
- const mismatchSignature = JSON.stringify({
- selectedText: diagnostics.selectedText,
- successLikeSummary: diagnostics.successLikeSummary,
- visibleSummary: diagnostics.visibleSummary,
- errorStyledSummary: diagnostics.errorStyledSummary,
+ if (diagnostics.hasCallbackSubmittedBadge && !diagnostics.hasExactSuccessVisibleBadge) {
+ const callbackSubmittedSignature = JSON.stringify({
+ callbackStatusText: diagnostics.callbackStatusText,
+ mainStatusText: diagnostics.mainStatusText,
});
- if (mismatchSignature !== lastSuccessLikeMismatchSignature) {
- lastSuccessLikeMismatchSignature = mismatchSignature;
- const errorStyledSuffix = diagnostics.hasErrorStyledVisibleBadge
- ? `;错误样式徽标:${diagnostics.errorStyledSummary}`
- : '';
+ if (callbackSubmittedSignature !== lastCallbackSubmittedSignature) {
+ lastCallbackSubmittedSignature = callbackSubmittedSignature;
log(
- `步骤 10:检测到“认证成功”相关徽标,但未命中精确成功条件。当前聚焦="${getInlineTextSnippet(diagnostics.selectedText || '(空)', 80)}";成功相关徽标:${diagnostics.successLikeSummary}${errorStyledSuffix}`,
- 'warn'
+ `步骤 10:CPA 已接受 localhost 回调,正在等待后台完成认证。回调提示=${formatStep10StatusSummaryValue(diagnostics.callbackStatusText)};主状态=${formatStep10StatusSummaryValue(diagnostics.mainStatusText)}`,
+ 'info'
);
- console.warn(LOG_PREFIX, '[Step 9] success-like badge detected without exact match', diagnostics);
+ console.info(LOG_PREFIX, '[Step 9] callback accepted and waiting for auth completion', diagnostics);
}
}
@@ -515,10 +708,11 @@ async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS
}
if (diagnostics.failureText) {
+ const failureExplanation = explainStep10Failure(diagnostics.failureText, diagnostics.failureSource || 'unknown');
if (isOAuthCallbackTimeoutFailure(diagnostics.failureText)) {
- throw new Error(`STEP9_OAUTH_TIMEOUT::${diagnostics.failureText}`);
+ throw new Error(`STEP9_OAUTH_TIMEOUT::${failureExplanation.userMessage}`);
}
- throw new Error(`STEP9_OAUTH_RETRY::${diagnostics.failureText}`);
+ throw new Error(`STEP9_OAUTH_RETRY::${failureExplanation.userMessage}`);
}
if (diagnostics.exactSuccessText) {
return diagnostics.exactSuccessText;
@@ -530,10 +724,18 @@ async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS
const finalText = finalDiagnostics.failureText || finalDiagnostics.selectedText;
const diagnosticsSuffix = ` 当前诊断:${finalDiagnostics.summary}`;
if (isOAuthCallbackTimeoutFailure(finalText)) {
- throw new Error(`STEP9_OAUTH_TIMEOUT::${finalText}${diagnosticsSuffix}`);
+ const failureExplanation = explainStep10Failure(finalText, finalDiagnostics.failureSource || 'main');
+ throw new Error(`STEP9_OAUTH_TIMEOUT::${failureExplanation.userMessage}${diagnosticsSuffix}`);
}
if (isStep9FailureText(finalText)) {
- throw new Error(`STEP9_OAUTH_RETRY::${finalText}${diagnosticsSuffix}`);
+ const failureExplanation = explainStep10Failure(finalText, finalDiagnostics.failureSource || 'unknown');
+ throw new Error(`STEP9_OAUTH_RETRY::${failureExplanation.userMessage}${diagnosticsSuffix}`);
+ }
+ if (finalDiagnostics.hasCallbackSubmittedBadge || finalDiagnostics.mainWaitingText) {
+ throw new Error(
+ 'STEP9_OAUTH_TIMEOUT::CPA 已接受回调,但 120 秒内仍未进入认证成功状态。通常是 CPA 后台处理过慢、面板轮询异常,或 CPA 到 OpenAI 的网络/代理存在问题。'
+ + diagnosticsSuffix
+ );
}
throw new Error(finalText
? `CPA 面板状态未进入成功状态,当前为“${finalText}”。${diagnosticsSuffix}`
@@ -583,6 +785,11 @@ function findCodexOAuthHeader() {
}) || null;
}
+function findCodexOAuthCard() {
+ const header = findCodexOAuthHeader();
+ return header?.closest('.card, [class*="card"]') || header || null;
+}
+
function findOAuthCardLoginButton(header) {
const card = header?.closest('.card, [class*="card"]') || header?.parentElement || document;
const candidates = card.querySelectorAll('button.btn.btn-primary, button.btn-primary, button.btn');
diff --git a/docs/使用教程.md b/docs/使用教程.md
index 90eda7b..431d716 100644
--- a/docs/使用教程.md
+++ b/docs/使用教程.md
@@ -1,24 +1,41 @@
-# Codex 注册扩展更新与 Clash Verge 非港轮询配置教程
-
-本教程用于说明如何更新 `Codex 注册扩展`,以及如何在 `Clash Verge` 中配置 `🔁 非港轮询`。
+本教程用于说明相关项目地址、扩展更新方式,以及 `Clash Verge` 的 `🔁 非港轮询` 配置方法。
## 适用场景
+- 需要拉取并部署 `cpa` 或 `sub2api` 项目
- 已经安装本扩展,想更新到最新版本
- 想用更方便的方式长期同步扩展更新
- 需要在 `Clash Verge` 中启用 `🔁 非港轮询`
## 准备内容
+- 可以访问 `GitHub`
- 已安装好的扩展文件夹
- 可以打开浏览器的 `扩展程序管理` 页面
-- 如需使用 `git pull`,本地已安装 `git`
-- 如需使用 `GitHub Desktop`,本地已安装 `GitHub Desktop`
+- 如需部署 `cpa`,部署环境必须可以访问 `OpenAI`
- 已安装 `Clash Verge`,并已导入可用订阅
## 操作步骤
-### 第一部分:更新扩展
+### 第一部分:相关项目地址与部署说明
+
+1. 查看项目地址
+ `cpa` 项目地址:
+ `sub2api` 项目地址:
+
+2. 拉取项目到本地
+ 先将你需要的项目拉取到本地目录。
+ 可以使用 `git clone`,也可以直接下载项目压缩包后解压。
+
+3. 让 AI 直接协助部署
+ 部署教程这里不单独展开。
+ 将项目拉取下来后,直接让 AI 帮你部署即可。
+
+4. 确认 `cpa` 的部署环境
+ 如果你部署的是 `cpa`,部署所在环境必须可以访问 `OpenAI`。
+ 否则可能会出现第十步显示认证成功,但实际上没有生成认证文件的情况。
+
+### 第二部分:更新扩展
1. 使用 `GitHub Desktop` 更新
先安装 `GitHub Desktop`。
@@ -40,16 +57,16 @@
找到该扩展后,手动点击一次 `重新加载`。
这一步一定要做,否则浏览器可能仍在使用旧版本。
-### 第二部分:配置 `Clash Verge` 的 `🔁 非港轮询`
+### 第三部分:配置 `Clash Verge` 的 `🔁 非港轮询`
#### 第一步:添加扩展脚本
1. 打开 `Clash Verge`,进入左侧的 `订阅`(`Profiles`)界面。
2. 在右上角或对应位置找到并双击打开 `全局扩展脚本`(`Merge/Script`)。
-3. 将里面的内容全部清空,替换为下方格式化好的代码。
+3. 将里面的内容全部清空,替换为下方脚本代码。
4. 点击保存,使用右上角保存按钮或按 `Ctrl+S`。
-💻 格式化后的代码(如果遇到格式错误,则让豆包修复一下)
+💻 脚本代码(如果遇到格式错误,可让豆包帮你修复后再粘贴)
```javascript
function uniqPrepend(arr, items) {
@@ -146,10 +163,28 @@ function main(config, profileName) {
4. 确认右上角的 `代理模式`(`Mode`)已经设置为 `规则模式`(`Rule`)。
5. 确认左侧 `设置` 中的 `系统代理`(`System Proxy`)已经开启。
+## 常见问题
+
+### 为什么第十步显示认证成功,但没有认证文件?
+
+这通常是因为 `cpa` 部署所在环境无法访问 `OpenAI`。请先确认部署环境可以正常访问 `OpenAI`,然后再重新执行相关认证步骤。
+
+### 为什么更新完扩展后还是旧版本?
+
+如果你只是替换了本地文件,但没有去浏览器的 `扩展程序管理` 页面点击 `重新加载`,浏览器不会立即启用新版本。请更新完成后手动重新加载一次该扩展。
+
+### `git pull` 提示不是 Git 仓库怎么办?
+
+这通常说明你进入的不是正确目录,或者当前文件夹不是通过 `git` 管理的版本。此时可以改用 `GitHub Desktop`,或者直接使用手动下载覆盖的方式更新。
+
+### 为什么没有看到 `🔁 非港轮询`?
+
+请先确认脚本已经完整粘贴并保存,然后回到 `代理` 页面重新查看。若仍未出现,请继续确认当前订阅可用、`代理模式` 为 `规则模式`,并且 `系统代理` 已开启。
## 注意事项
- 不论使用哪种方式更新扩展,更新完成后都必须在浏览器的 `扩展程序管理` 页面重新加载一次该扩展。
+- 如需部署 `cpa`,请先确认部署环境可以访问 `OpenAI`,否则可能出现第十步认证成功但没有认证文件的问题。
- 如果你长期更新扩展,优先使用 `git pull`,这是最方便、最推荐的方式。
- 使用手动覆盖更新时,请直接覆盖当前扩展文件夹,避免同时保留多份不同版本的目录。
-- 在 `Clash Verge` 中粘贴脚本时,请先清空旧内容,再完整粘贴新代码并保存。
+- 在 `Clash Verge` 中粘贴脚本时,请先清空旧内容,再完整粘贴新代码并保存。
\ No newline at end of file
diff --git a/tests/activation-utils.test.js b/tests/activation-utils.test.js
index 181c597..6f3a4ba 100644
--- a/tests/activation-utils.test.js
+++ b/tests/activation-utils.test.js
@@ -81,6 +81,16 @@ test('isRecoverableStep9AuthFailure matches timeout and CPA auth failure statuse
true
);
+ assert.equal(
+ isRecoverableStep9AuthFailure('提交回调失败:请更新CLI Proxy API或检查连接'),
+ true
+ );
+
+ assert.equal(
+ isRecoverableStep9AuthFailure('认证失败:Bad Request'),
+ true
+ );
+
assert.equal(
isRecoverableStep9AuthFailure('认证成功!'),
false
diff --git a/tests/step9-status-diagnostics.test.js b/tests/step9-status-diagnostics.test.js
index 17512c5..2f9c9a1 100644
--- a/tests/step9-status-diagnostics.test.js
+++ b/tests/step9-status-diagnostics.test.js
@@ -58,23 +58,32 @@ const bundle = [
extractFunction('summarizeStatusBadgeEntries'),
extractFunction('normalizeStep9StatusText'),
extractFunction('isOAuthCallbackTimeoutFailure'),
+ extractFunction('isStep10CallbackSubmittedStatus'),
+ extractFunction('isStep10CallbackFailureText'),
+ extractFunction('isStep10MainWaitingStatus'),
+ extractFunction('isStep10MainFailureText'),
extractFunction('isStep9FailureText'),
extractFunction('isStep9SuccessStatus'),
extractFunction('isStep9SuccessLikeStatus'),
+ extractFunction('formatStep10StatusSummaryValue'),
extractFunction('buildStep9StatusDiagnostics'),
+ extractFunction('extractStep10FailureDetail'),
+ extractFunction('explainStep10Failure'),
].join('\n');
function createApi() {
return new Function(`
function isRecoverableStep9AuthFailure(text) {
- return /(?:认证失败|回调 URL 提交失败):\\s*/i.test(String(text || '').trim())
- || /oauth flow is not pending/i.test(String(text || '').trim());
+ const normalized = String(text || '').trim();
+ return /(?:认证失败|回调\\s*url\\s*提交失败|回调url提交失败|提交回调失败)\\s*[::]?/i.test(normalized)
+ || /oauth flow is not pending|请更新\\s*cli\\s*proxy\\s*api\\s*或检查连接|bad request|state code error|failed to exchange authorization code for tokens|failed to save authentication tokens|unknown or expired state|invalid state|state is required|code or error is required|invalid redirect_url|provider does not match state|failed to persist oauth callback|timeout waiting for oauth callback|oauth flow timed out/i.test(normalized);
}
${bundle}
return {
buildStep9StatusDiagnostics,
+ explainStep10Failure,
};
`)();
}
@@ -86,6 +95,7 @@ test('step 9 does not treat red success badges as exact success', () => {
visible: true,
text: '认证成功!',
className: 'status-badge text-danger',
+ location: 'main',
hasErrorVisualSignal: true,
errorVisualSummary: 'color=rgb(220, 38, 38)',
},
@@ -104,23 +114,89 @@ test('step 9 keeps failure state dominant when success badge and error banner co
visible: true,
text: '认证成功!',
className: 'status-badge',
+ location: 'main',
hasErrorVisualSignal: false,
errorVisualSummary: '',
},
- ],
- [
{
visible: true,
text: '回调 URL 提交失败: oauth flow is not pending',
- className: 'alert alert-danger',
+ className: 'status-badge error',
+ location: 'callback',
hasErrorVisualSignal: true,
errorVisualSummary: 'color=rgb(220, 38, 38)',
},
],
+ [],
'page'
);
assert.equal(diagnostics.hasExactSuccessVisibleBadge, true);
assert.equal(diagnostics.hasFailureVisibleBadge, true);
assert.equal(diagnostics.failureText, '回调 URL 提交失败: oauth flow is not pending');
+ assert.equal(diagnostics.failureSource, 'callback');
+});
+
+test('step 10 treats plain 认证成功 as success when no failure is visible', () => {
+ const api = createApi();
+ const diagnostics = api.buildStep9StatusDiagnostics(
+ [
+ {
+ visible: true,
+ text: '认证成功',
+ className: 'status-badge',
+ location: 'main',
+ hasErrorVisualSignal: false,
+ errorVisualSummary: '',
+ },
+ ],
+ [],
+ 'page'
+ );
+
+ assert.equal(diagnostics.hasExactSuccessVisibleBadge, true);
+ assert.equal(diagnostics.exactSuccessText, '认证成功');
+});
+
+test('step 10 recognizes callback accepted badge as in-progress signal', () => {
+ const api = createApi();
+ const diagnostics = api.buildStep9StatusDiagnostics(
+ [
+ {
+ visible: true,
+ text: '回调 URL 已提交,等待认证中...',
+ className: 'status-badge success',
+ location: 'callback',
+ hasErrorVisualSignal: false,
+ errorVisualSummary: '',
+ },
+ {
+ visible: true,
+ text: '等待认证中...',
+ className: 'status-badge',
+ location: 'main',
+ hasErrorVisualSignal: false,
+ errorVisualSummary: '',
+ },
+ ],
+ [],
+ 'page'
+ );
+
+ assert.equal(diagnostics.hasCallbackSubmittedBadge, true);
+ assert.equal(diagnostics.callbackSubmittedText, '回调 URL 已提交,等待认证中...');
+ assert.equal(diagnostics.mainWaitingText, '等待认证中...');
+ assert.equal(diagnostics.hasExactSuccessVisibleBadge, false);
+});
+
+test('step 10 explains callback upgrade hint with user-friendly reason', () => {
+ const api = createApi();
+ const explanation = api.explainStep10Failure(
+ '回调 URL 提交失败: 请更新CLI Proxy API或检查连接',
+ 'callback'
+ );
+
+ assert.equal(explanation.code, 'callback_submit_api_unavailable');
+ assert.match(explanation.userMessage, /CLI Proxy API 版本过旧|管理接口未启动|连接异常/);
+ assert.match(explanation.userMessage, /回调提交阶段/);
});
From a6967240c7e5a4f394925643fc9a0161a8a50ec6 Mon Sep 17 00:00:00 2001
From: QLHazyCoder <2825305047@qq.com>
Date: Wed, 22 Apr 2026 18:19:17 +0800
Subject: [PATCH 18/23] =?UTF-8?q?fix:=20=E5=B0=86=20OAuth=20=E6=B5=81?=
=?UTF-8?q?=E7=A8=8B=E8=B6=85=E6=97=B6=E8=AE=BE=E7=BD=AE=E4=BB=8E=206=20?=
=?UTF-8?q?=E5=88=86=E9=92=9F=E8=B0=83=E6=95=B4=E4=B8=BA=205=20=E5=88=86?=
=?UTF-8?q?=E9=92=9F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
background.js | 4 ++--
tests/background-auth-chain-guard.test.js | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/background.js b/background.js
index 7297909..a07a4d9 100644
--- a/background.js
+++ b/background.js
@@ -151,7 +151,7 @@ const HUMAN_STEP_DELAY_MIN = 700;
const HUMAN_STEP_DELAY_MAX = 2200;
const STEP6_MAX_ATTEMPTS = 3;
const STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS = 8;
-const OAUTH_FLOW_TIMEOUT_MS = 6 * 60 * 1000;
+const OAUTH_FLOW_TIMEOUT_MS = 5 * 60 * 1000;
const SUB2API_STEP1_RESPONSE_TIMEOUT_MS = 90000;
const SUB2API_STEP9_RESPONSE_TIMEOUT_MS = 120000;
const DEFAULT_SUB2API_URL = 'https://sub2api.hisence.fun/admin/accounts';
@@ -6836,7 +6836,7 @@ async function startOAuthFlowTimeoutWindow(options = {}) {
oauthFlowDeadlineAt: deadlineAt,
oauthFlowDeadlineSourceUrl: normalizeOAuthFlowSourceUrl(options.oauthUrl),
});
- await addLog(`步骤 ${step}:已拿到新的 OAuth 登录地址,开始 6 分钟倒计时。`, 'info');
+ await addLog(`步骤 ${step}:已拿到新的 OAuth 登录地址,开始 ${Math.round(OAUTH_FLOW_TIMEOUT_MS / 60000)} 分钟倒计时。`, 'info');
return deadlineAt;
}
diff --git a/tests/background-auth-chain-guard.test.js b/tests/background-auth-chain-guard.test.js
index 3fec903..0353613 100644
--- a/tests/background-auth-chain-guard.test.js
+++ b/tests/background-auth-chain-guard.test.js
@@ -162,7 +162,7 @@ return {
test('oauth timeout budget ignores stale deadlines from an old oauth url', async () => {
const api = new Function(`
const LOG_PREFIX = '[test]';
-const OAUTH_FLOW_TIMEOUT_MS = 6 * 60 * 1000;
+const OAUTH_FLOW_TIMEOUT_MS = 5 * 60 * 1000;
${extractFunction('normalizeOAuthFlowDeadlineAt')}
${extractFunction('normalizeOAuthFlowSourceUrl')}
${extractFunction('getOAuthFlowRemainingMs')}
From 79e917345e7326ec669d0223ac8f40dff70af3e6 Mon Sep 17 00:00:00 2001
From: QLHazyCoder <2825305047@qq.com>
Date: Wed, 22 Apr 2026 18:33:54 +0800
Subject: [PATCH 19/23] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=B5=8F?=
=?UTF-8?q?=E8=A7=88=E5=99=A8=E5=88=87=E6=8D=A2=E6=89=80=E9=9C=80=E9=94=99?=
=?UTF-8?q?=E8=AF=AF=E5=A4=84=E7=90=86=EF=BC=8C=E5=A2=9E=E5=BC=BA=E6=AD=A5?=
=?UTF-8?q?=E9=AA=A4=2010=20=E7=9A=84=E8=AF=8A=E6=96=AD=E4=BF=A1=E6=81=AF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
background.js | 23 ++++++
content/vps-panel.js | 21 +++++
tests/background-auth-chain-guard.test.js | 94 +++++++++++++++++++++++
tests/step9-status-diagnostics.test.js | 33 ++++++++
4 files changed, 171 insertions(+)
diff --git a/background.js b/background.js
index a07a4d9..93511a7 100644
--- a/background.js
+++ b/background.js
@@ -147,6 +147,7 @@ const HOTMAIL_MAILBOXES = ['INBOX', 'Junk'];
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
const CLOUDFLARE_SECURITY_BLOCK_ERROR_PREFIX = 'CF_SECURITY_BLOCKED::';
const CLOUDFLARE_SECURITY_BLOCK_USER_MESSAGE = '您已触发Cloudflare 安全防护系统,已完全停止流程,请不要短时间内多次进行重新发送验证码,连续刷新、反复点击重试会加重风控;请先关闭页面等待 15-30 分钟,让系统的临时限制自动解除。或者更换浏览器';
+const BROWSER_SWITCH_REQUIRED_ERROR_PREFIX = 'BROWSER_SWITCH_REQUIRED::';
const HUMAN_STEP_DELAY_MIN = 700;
const HUMAN_STEP_DELAY_MAX = 2200;
const STEP6_MAX_ATTEMPTS = 3;
@@ -4000,6 +4001,17 @@ function getTerminalSecurityBlockedTitle(error) {
return 'Cloudflare 风控拦截';
}
+function isBrowserSwitchRequiredError(error) {
+ return getErrorMessage(error).startsWith(BROWSER_SWITCH_REQUIRED_ERROR_PREFIX);
+}
+
+function getBrowserSwitchRequiredMessage(error) {
+ const message = getErrorMessage(error);
+ return message.startsWith(BROWSER_SWITCH_REQUIRED_ERROR_PREFIX)
+ ? message.slice(BROWSER_SWITCH_REQUIRED_ERROR_PREFIX.length).trim()
+ : message;
+}
+
function broadcastSecurityBlockedAlert(title = '流程已完全停止', message = CLOUDFLARE_SECURITY_BLOCK_USER_MESSAGE, alertText = '检测到 Cloudflare 风控,请暂停当前操作。') {
chrome.runtime.sendMessage({
type: 'SECURITY_BLOCKED_ALERT',
@@ -4023,6 +4035,13 @@ async function handleCloudflareSecurityBlocked(error) {
return message;
}
+async function handleBrowserSwitchRequired(error) {
+ const message = getBrowserSwitchRequiredMessage(error)
+ || '检测到第 10 步的特殊冲突状态,请更换浏览器后重新进行注册登录。';
+ await requestStop({ logMessage: message });
+ return message;
+}
+
function isVerificationMailPollingError(error) {
if (typeof loggingStatus !== 'undefined' && loggingStatus?.isVerificationMailPollingError) {
return loggingStatus.isVerificationMailPollingError(error);
@@ -5326,6 +5345,10 @@ async function executeStep(step, options = {}) {
await handleCloudflareSecurityBlocked(err);
throw new Error(STOP_ERROR_MESSAGE);
}
+ if (isBrowserSwitchRequiredError(err)) {
+ await handleBrowserSwitchRequired(err);
+ throw new Error(STOP_ERROR_MESSAGE);
+ }
if (!(deferRetryableTransportError && doesStepUseCompletionSignal(step) && isRetryableContentScriptTransportError(err))) {
await setStepStatus(step, 'failed');
await addLog(`步骤 ${step} 失败:${err.message}`, 'error');
diff --git a/content/vps-panel.js b/content/vps-panel.js
index eef4337..63af31c 100644
--- a/content/vps-panel.js
+++ b/content/vps-panel.js
@@ -438,6 +438,20 @@ function formatStep10StatusSummaryValue(text, emptyText = '无') {
return text ? `"${getInlineTextSnippet(text, 80)}"` : emptyText;
}
+function isStep10BrowserSwitchRequiredConflict(diagnostics = {}) {
+ return Boolean(diagnostics?.hasExactSuccessVisibleBadge)
+ && /请更新\s*cli\s*proxy\s*api\s*或检查连接/i.test(String(diagnostics?.callbackFailureText || ''));
+}
+
+function getStep10BrowserSwitchRequiredMessage(diagnostics = {}) {
+ const callbackFailureText = normalizeStep9StatusText(diagnostics?.callbackFailureText || '');
+ return [
+ '检测到 CPA 页面同时显示“认证成功”和“回调 URL 提交失败: 请更新CLI Proxy API或检查连接”。',
+ '这类冲突状态通常通过更换浏览器可以解决,请更换浏览器后重新进行注册登录。',
+ callbackFailureText ? `面板原文:${callbackFailureText}` : '',
+ ].filter(Boolean).join(' ');
+}
+
function buildStep9StatusDiagnostics(entries = [], pageErrorEntries = [], pageSnippet = '') {
const visibleEntries = entries.filter((entry) => entry.visible);
const callbackEntries = visibleEntries.filter((entry) => entry.location === 'callback');
@@ -688,6 +702,13 @@ async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS
}
}
+ if (isStep10BrowserSwitchRequiredConflict(diagnostics)) {
+ const browserSwitchMessage = getStep10BrowserSwitchRequiredMessage(diagnostics);
+ log(`步骤 10:${browserSwitchMessage}`, 'error');
+ console.error(LOG_PREFIX, '[Step 9] browser-switch conflict detected', diagnostics);
+ throw new Error(`BROWSER_SWITCH_REQUIRED::${browserSwitchMessage}`);
+ }
+
if (diagnostics.hasExactSuccessVisibleBadge && diagnostics.hasFailureVisibleBadge) {
const conflictSignature = JSON.stringify({
exactSuccessSummary: diagnostics.exactSuccessSummary,
diff --git a/tests/background-auth-chain-guard.test.js b/tests/background-auth-chain-guard.test.js
index 0353613..b781570 100644
--- a/tests/background-auth-chain-guard.test.js
+++ b/tests/background-auth-chain-guard.test.js
@@ -71,6 +71,7 @@ test('executeStep reuses the active top-level auth chain instead of starting a d
const api = new Function(`
const LOG_PREFIX = '[test]';
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
+const BROWSER_SWITCH_REQUIRED_ERROR_PREFIX = 'BROWSER_SWITCH_REQUIRED::';
const AUTH_CHAIN_STEP_IDS = new Set([7, 8, 9, 10]);
let activeTopLevelAuthChainExecution = null;
let stopRequested = false;
@@ -106,6 +107,10 @@ function isTerminalSecurityBlockedError() {
return false;
}
async function handleCloudflareSecurityBlocked() {}
+function isBrowserSwitchRequiredError() {
+ return false;
+}
+async function handleBrowserSwitchRequired() {}
function doesStepUseCompletionSignal() {
return false;
}
@@ -159,6 +164,95 @@ return {
assert.ok(events.logs.some(({ message }) => /复用当前授权链/.test(message)));
});
+test('executeStep stops flow when browser-switch-required error is raised', async () => {
+ const api = new Function(`
+const LOG_PREFIX = '[test]';
+const STOP_ERROR_MESSAGE = '流程已被用户停止。';
+const BROWSER_SWITCH_REQUIRED_ERROR_PREFIX = 'BROWSER_SWITCH_REQUIRED::';
+const AUTH_CHAIN_STEP_IDS = new Set([7, 8, 9, 10]);
+let activeTopLevelAuthChainExecution = null;
+let stopRequested = false;
+const events = {
+ logs: [],
+ statusCalls: [],
+ stopRequests: [],
+ appendRecords: [],
+};
+
+async function addLog(message, level = 'info') {
+ events.logs.push({ message, level });
+}
+async function setStepStatus(step, status) {
+ events.statusCalls.push({ step, status });
+}
+async function humanStepDelay() {}
+async function getState() {
+ return {
+ flowStartTime: null,
+ stepStatuses: {},
+ };
+}
+function getErrorMessage(error) {
+ return error?.message || String(error || '');
+}
+async function appendManualAccountRunRecordIfNeeded(status, _state, reason) {
+ events.appendRecords.push({ status, reason });
+}
+function isTerminalSecurityBlockedError() {
+ return false;
+}
+async function handleCloudflareSecurityBlocked() {}
+async function requestStop(options = {}) {
+ events.stopRequests.push(options);
+}
+function doesStepUseCompletionSignal() {
+ return false;
+}
+function isRetryableContentScriptTransportError() {
+ return false;
+}
+const stepRegistry = {
+ async executeStep() {
+ throw new Error('BROWSER_SWITCH_REQUIRED::请更换浏览器进行注册登录。');
+ },
+};
+
+${extractFunction('isStopError')}
+${extractFunction('throwIfStopped')}
+${extractFunction('isAuthChainStep')}
+${extractFunction('acquireTopLevelAuthChainExecution')}
+${extractFunction('isBrowserSwitchRequiredError')}
+${extractFunction('getBrowserSwitchRequiredMessage')}
+${extractFunction('handleBrowserSwitchRequired')}
+${extractFunction('executeStep')}
+
+return {
+ executeStep,
+ snapshot() {
+ return events;
+ },
+};
+`)();
+
+ await assert.rejects(
+ () => api.executeStep(10),
+ /流程已被用户停止。/
+ );
+
+ const events = api.snapshot();
+ assert.deepStrictEqual(events.stopRequests, [
+ { logMessage: '请更换浏览器进行注册登录。' },
+ ]);
+ assert.deepStrictEqual(events.statusCalls, [
+ { step: 10, status: 'running' },
+ ]);
+ assert.equal(
+ events.logs.some(({ message }) => /步骤 10 失败/.test(message)),
+ false,
+ 'browser-switch-required error should stop the flow before generic failed logging'
+ );
+});
+
test('oauth timeout budget ignores stale deadlines from an old oauth url', async () => {
const api = new Function(`
const LOG_PREFIX = '[test]';
diff --git a/tests/step9-status-diagnostics.test.js b/tests/step9-status-diagnostics.test.js
index 2f9c9a1..d342666 100644
--- a/tests/step9-status-diagnostics.test.js
+++ b/tests/step9-status-diagnostics.test.js
@@ -66,6 +66,8 @@ const bundle = [
extractFunction('isStep9SuccessStatus'),
extractFunction('isStep9SuccessLikeStatus'),
extractFunction('formatStep10StatusSummaryValue'),
+ extractFunction('isStep10BrowserSwitchRequiredConflict'),
+ extractFunction('getStep10BrowserSwitchRequiredMessage'),
extractFunction('buildStep9StatusDiagnostics'),
extractFunction('extractStep10FailureDetail'),
extractFunction('explainStep10Failure'),
@@ -84,6 +86,8 @@ ${bundle}
return {
buildStep9StatusDiagnostics,
explainStep10Failure,
+ isStep10BrowserSwitchRequiredConflict,
+ getStep10BrowserSwitchRequiredMessage,
};
`)();
}
@@ -200,3 +204,32 @@ test('step 10 explains callback upgrade hint with user-friendly reason', () => {
assert.match(explanation.userMessage, /CLI Proxy API 版本过旧|管理接口未启动|连接异常/);
assert.match(explanation.userMessage, /回调提交阶段/);
});
+
+test('step 10 requests browser switch when success badge and callback upgrade failure coexist', () => {
+ const api = createApi();
+ const diagnostics = api.buildStep9StatusDiagnostics(
+ [
+ {
+ visible: true,
+ text: '认证成功',
+ className: 'status-badge success',
+ location: 'main',
+ hasErrorVisualSignal: false,
+ errorVisualSummary: '',
+ },
+ {
+ visible: true,
+ text: '回调 URL 提交失败: 请更新CLI Proxy API或检查连接',
+ className: 'status-badge error',
+ location: 'callback',
+ hasErrorVisualSignal: true,
+ errorVisualSummary: 'color=rgb(220, 38, 38)',
+ },
+ ],
+ [],
+ 'page'
+ );
+
+ assert.equal(api.isStep10BrowserSwitchRequiredConflict(diagnostics), true);
+ assert.match(api.getStep10BrowserSwitchRequiredMessage(diagnostics), /更换浏览器后重新进行注册登录/);
+});
From 69434d67eed960e26edc9382b4760fa10f3edeff Mon Sep 17 00:00:00 2001
From: QLHazyCoder <2825305047@qq.com>
Date: Wed, 22 Apr 2026 20:09:47 +0800
Subject: [PATCH 20/23] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E5=9B=9E?=
=?UTF-8?q?=E8=B0=83=E6=96=B9=E5=BC=8F=E9=80=89=E9=A1=B9=EF=BC=8C=E4=BC=98?=
=?UTF-8?q?=E5=8C=96=E6=AD=A5=E9=AA=A4=2010=20=E7=AD=96=E7=95=A5=E9=80=89?=
=?UTF-8?q?=E6=8B=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 6 +++---
sidepanel/sidepanel.html | 8 ++++----
2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index 43468ac..3589b79 100644
--- a/README.md
+++ b/README.md
@@ -639,9 +639,9 @@ Step 8 默认要求当前认证页已经处于登录验证码页。
- 步骤 10 会拒绝任何不是真实 `/auth/callback`,或缺少 `code` / `state` 的本地回调地址
- 成功后的清理只会针对 `/auth` 这一类真实回调标签页,不会再泛化清理任意 localhost 路径
-- 侧边栏可切换“本地 CPA”策略,默认是 `全部回调`
-- 选择 `全部回调` 时,即使 CPA 部署在本地,也会执行步骤 10
-- 选择 `跳过第10步` 时,仅当本地 CPA 且步骤 9 已拿到回调地址时,才会直接跳过步骤 10
+- 侧边栏可切换“回调方式”,默认是 `服务器部署`
+- 选择 `服务器部署` 时,即使 CPA 部署在本地,也会执行步骤 10
+- 选择 `本地部署` 时,仅当本地 CPA 且步骤 9 已拿到回调地址时,才会直接跳过步骤 10
回到 CPA 面板:
diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html
index 6b99430..835ae4b 100644
--- a/sidepanel/sidepanel.html
+++ b/sidepanel/sidepanel.html
@@ -164,10 +164,10 @@