feat: 增强2925邮箱登录流程,添加登录失败处理和超时机制,更新相关文档和测试用例

This commit is contained in:
QLHazyCoder
2026-04-20 23:10:01 +08:00
parent 20146ce3e9
commit 6c17ffc7f4
7 changed files with 392 additions and 27 deletions
+1 -1
View File
@@ -145,7 +145,7 @@
2.`2925 账号池` 中添加 `邮箱 / 密码`
3. 在“2925 基邮箱”右侧打开 `号池` 开关后,基邮箱输入框会切成下拉框,只能从 2925 账号池中选择邮箱;关闭开关则回退到原来的手填基邮箱路线
4. 可先点 `使用此账号` 让当前别名基邮箱切到这条 2925 账号,再点 `登录` 手动验证网页邮箱登录态
5. 只有在 `号池` 开关开启时,自动流程执行到 Step 4 / Step 8 前才会自动检查 2925 登录态;如果未登录,会直接使用当前账号自动登录
5. 只有在 `号池` 开关开启时,自动流程执行到 Step 4 / Step 8 前才会自动检查 2925 登录态;如果未登录,会直接使用当前账号自动登录;点击登录后若 `20 秒`内仍未进入收件箱,则会判定当前登录失败
6. 当 Step 4 / Step 8 轮询邮箱时遇到“子邮箱已达上限邮箱”,扩展会记录当前时间;如果还有下一个可用账号,就禁用当前账号 24 小时并自动切换登录;如果没有下一个可用账号,或当前未启用号池模式,则会直接复用现有“手动暂停 / 停止”逻辑终止自动流程
7. 如果你同时开启了 `Auto` 的自动重试,当前尝试结束后会按现有逻辑自动进入下一次尝试,不需要再手动介入
8. `Mail = 2925` 仍然走 Gmail / 2925 共用的别名邮箱链路;实际注册邮箱会基于当前 2925 账号邮箱生成,例如 `name@2925.com -> name123456@2925.com`
+1
View File
@@ -5387,6 +5387,7 @@ const mail2925SessionManager = self.MultiPageBackgroundMail2925Session?.createMa
findMail2925Account,
getMail2925AccountStatus,
getState,
isAutoRunLockedState,
isMail2925AccountAvailable: self.Mail2925Utils?.isMail2925AccountAvailable,
MAIL2925_LIMIT_COOLDOWN_MS,
normalizeMail2925Account,
+50 -3
View File
@@ -14,6 +14,7 @@
normalizeMail2925Accounts,
pickMail2925AccountForRun,
getState,
isAutoRunLockedState,
requestStop,
reuseOrCreateTab,
sendToMailContentScriptResilient,
@@ -60,6 +61,25 @@
return new Error(`${MAIL2925_THREAD_TERMINATED_ERROR_PREFIX}${String(message || '').trim()}`);
}
async function stopAutoRunForMail2925LoginFailure(errorMessage = '') {
if (typeof requestStop !== 'function') {
return false;
}
const state = await getState();
const autoRunning = typeof isAutoRunLockedState === 'function'
? isAutoRunLockedState(state)
: Boolean(state?.autoRunning);
if (!autoRunning) {
return false;
}
await requestStop({
logMessage: errorMessage || '2925 登录失败,已按手动停止逻辑暂停自动流程。',
});
return true;
}
function isMail2925LimitReachedError(error) {
const message = getErrorMessage(error);
return message.startsWith(MAIL2925_LIMIT_ERROR_PREFIX)
@@ -352,7 +372,9 @@
injectSource: MAIL2925_INJECT_SOURCE,
});
const result = await sendToMailContentScriptResilient(
let result;
try {
result = await sendToMailContentScriptResilient(
getMail2925MailConfig(),
{
type: 'ENSURE_MAIL2925_SESSION',
@@ -365,13 +387,38 @@
},
},
{
timeoutMs: forceRelogin ? 90000 : 45000,
responseTimeoutMs: forceRelogin ? 90000 : 45000,
timeoutMs: forceRelogin ? 30000 : 25000,
responseTimeoutMs: forceRelogin ? 30000 : 25000,
maxRecoveryAttempts: 2,
}
);
} catch (err) {
const stopped = await stopAutoRunForMail2925LoginFailure(
`2925${actionLabel}失败(${getErrorMessage(err) || '20 秒内未进入收件箱'}),已按手动停止逻辑暂停自动流程。`
);
if (stopped) {
throw new Error('流程已被用户停止。');
}
throw err;
}
if (!result?.loggedIn) {
const stopped = await stopAutoRunForMail2925LoginFailure(
`2925${actionLabel}失败(20 秒内未进入收件箱),已按手动停止逻辑暂停自动流程。`
);
if (stopped) {
throw new Error('流程已被用户停止。');
}
throw new Error(`2925${actionLabel}失败,当前页面仍未进入收件箱。`);
}
if (result?.error) {
const stopped = await stopAutoRunForMail2925LoginFailure(
`2925${actionLabel}失败(${result.error}),已按手动停止逻辑暂停自动流程。`
);
if (stopped) {
throw new Error('流程已被用户停止。');
}
throw new Error(result.error);
}
if (result?.limitReached) {
+83 -21
View File
@@ -193,6 +193,12 @@ const MAIL2925_AGREEMENT_PATTERNS = [
/服务协议/,
/隐私政策/,
];
const MAIL2925_REMEMBER_LOGIN_PATTERNS = [
/30天内免登录/,
/免登录/,
/记住登录/,
/保持登录/,
];
function normalizeNodeText(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
@@ -405,46 +411,102 @@ function findAgreementContainer() {
return null;
}
function findAgreementCheckbox() {
const agreementContainer = findAgreementContainer();
if (agreementContainer) {
const checkbox = agreementContainer.querySelector('input[type="checkbox"], [role="checkbox"], .ivu-checkbox, .el-checkbox');
if (checkbox) {
return resolveActionTarget(checkbox);
}
const nearbyCheckbox = agreementContainer.parentElement?.querySelector?.('input[type="checkbox"], [role="checkbox"], .ivu-checkbox, .el-checkbox');
if (nearbyCheckbox) {
return resolveActionTarget(nearbyCheckbox);
function isAgreementText(text = '') {
const normalizedText = normalizeNodeText(text);
if (!normalizedText || MAIL2925_REMEMBER_LOGIN_PATTERNS.some((pattern) => pattern.test(normalizedText))) {
return false;
}
return /我已阅读并同意/.test(normalizedText)
|| (/服务协议/.test(normalizedText) && /隐私政策/.test(normalizedText));
}
function getCheckboxContextText(target) {
if (!target) {
return '';
}
const textParts = [];
const candidates = [
target,
target.closest?.('label'),
target.parentElement,
target.parentElement?.parentElement,
target.nextElementSibling,
target.previousElementSibling,
target.closest?.('label, div, span, p, li, form'),
].filter(Boolean);
for (const candidate of candidates) {
const text = normalizeNodeText(candidate.innerText || candidate.textContent || '');
if (text) {
textParts.push(text);
}
}
return normalizeNodeText(textParts.join(' '));
}
function findAgreementCheckbox() {
const genericCheckboxes = document.querySelectorAll('input[type="checkbox"], [role="checkbox"], .ivu-checkbox, .el-checkbox');
for (const checkbox of genericCheckboxes) {
const target = resolveActionTarget(checkbox);
if (!isVisibleNode(target)) {
continue;
}
const wrapper = target.closest('label, div, span') || target.parentElement || target;
const text = normalizeNodeText(wrapper?.innerText || wrapper?.textContent || '');
if (/我已阅读并同意/.test(text) || /服务协议/.test(text) || /隐私政策/.test(text)) {
const contextText = getCheckboxContextText(target);
if (MAIL2925_REMEMBER_LOGIN_PATTERNS.some((pattern) => pattern.test(contextText))) {
continue;
}
if (isAgreementText(contextText)) {
return target;
}
}
const agreementContainer = findAgreementContainer();
if (agreementContainer) {
const checkbox = agreementContainer.querySelector('input[type="checkbox"], [role="checkbox"], .ivu-checkbox, .el-checkbox');
if (checkbox) {
const target = resolveActionTarget(checkbox);
const contextText = getCheckboxContextText(target);
if (!MAIL2925_REMEMBER_LOGIN_PATTERNS.some((pattern) => pattern.test(contextText))) {
return target;
}
}
const nearbyCheckbox = agreementContainer.parentElement?.querySelector?.('input[type="checkbox"], [role="checkbox"], .ivu-checkbox, .el-checkbox');
if (nearbyCheckbox) {
const target = resolveActionTarget(nearbyCheckbox);
const contextText = getCheckboxContextText(target);
if (!MAIL2925_REMEMBER_LOGIN_PATTERNS.some((pattern) => pattern.test(contextText))) {
return target;
}
}
}
return null;
}
async function ensureAgreementChecked() {
const agreementCheckbox = findAgreementCheckbox();
if (!agreementCheckbox) {
const checkboxes = Array.from(document.querySelectorAll('input[type="checkbox"], [role="checkbox"], .ivu-checkbox, .el-checkbox'))
.map((checkbox) => resolveActionTarget(checkbox))
.filter((target, index, list) => target && isVisibleNode(target) && list.indexOf(target) === index);
if (!checkboxes.length) {
return false;
}
if (isCheckboxChecked(agreementCheckbox)) {
return true;
let changed = false;
for (const checkbox of checkboxes) {
if (isCheckboxChecked(checkbox)) {
continue;
}
simulateClick(checkbox);
changed = true;
await sleep(120);
}
simulateClick(agreementCheckbox);
await sleep(150);
return isCheckboxChecked(agreementCheckbox);
return changed || checkboxes.every((checkbox) => isCheckboxChecked(checkbox));
}
function detectMail2925ViewState() {
@@ -832,7 +894,7 @@ async function ensureMail2925Session(payload = {}) {
await sleep(200);
simulateClick(loginButton);
const finalState = await waitForMail2925View('mailbox', 60000);
const finalState = await waitForMail2925View('mailbox', 20000);
if (finalState.view !== 'mailbox') {
throw new Error('2925:提交账号密码后未进入收件箱。');
}
@@ -159,3 +159,69 @@ test('handleMail2925LimitReachedError requests stop when no next mail2925 accoun
assert.equal(events.stopCalls.length, 1);
assert.match(events.stopCalls[0].logMessage, /没有可切换的下一个账号/);
});
test('ensureMail2925MailboxSession requests stop when auto run is active and login does not reach mailbox', 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 = {
autoRunning: true,
autoRunPhase: 'running',
mail2925Accounts: mail2925Utils.normalizeMail2925Accounts([
{ id: 'acc-1', email: 'acc1@2925.com', password: 'p1', enabled: true, lastUsedAt: 10 },
]),
currentMail2925AccountId: 'acc-1',
};
const events = {
stopCalls: [],
};
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,
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 = {}) => {
events.stopCalls.push(options);
},
reuseOrCreateTab: async () => 1,
sendToMailContentScriptResilient: async () => ({ loggedIn: false }),
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: true,
actionLabel: '步骤 4:确认 2925 邮箱登录态',
}),
/流程已被用户停止。/
);
assert.equal(events.stopCalls.length, 1);
assert.match(events.stopCalls[0].logMessage, /20 秒内未进入收件箱/);
});
+188
View File
@@ -4,6 +4,10 @@ const fs = require('node:fs');
const source = fs.readFileSync('content/mail-2925.js', 'utf8');
test('ensureMail2925Session waits at most 20 seconds for mailbox after clicking login', () => {
assert.match(source, /waitForMail2925View\('mailbox',\s*20000\)/);
});
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
const start = markers
@@ -488,3 +492,187 @@ return {
assert.equal(result, true);
assert.deepEqual(api.getCalls(), ['inbox', 'select-all', 'delete']);
});
test('findAgreementCheckbox skips 30-day login checkbox and picks agreement checkbox', async () => {
const bundle = [
extractFunction('normalizeNodeText'),
extractFunction('isVisibleNode'),
extractFunction('resolveActionTarget'),
extractFunction('findAgreementContainer'),
extractFunction('isAgreementText'),
extractFunction('getCheckboxContextText'),
extractFunction('findAgreementCheckbox'),
].join('\n');
const api = new Function(`
const MAIL2925_REMEMBER_LOGIN_PATTERNS = [
/30天内免登录/,
/免登录/,
/记住登录/,
/保持登录/,
];
const MAIL2925_AGREEMENT_PATTERNS = [
/我已阅读并同意/,
/服务协议/,
/隐私政策/,
];
const rememberCheckbox = {
kind: 'remember-checkbox',
disabled: false,
readOnly: false,
hidden: false,
classList: { contains() { return false; } },
getBoundingClientRect() { return { width: 14, height: 14 }; },
closest(selector) {
if (selector === 'button, [role="button"], a, label, .el-checkbox, .el-checkbox__input') return this;
if (selector === 'label') return rememberLabel;
if (selector === 'label, div, span, p, li, form') return rememberLabel;
return null;
},
};
const agreementCheckbox = {
kind: 'agreement-checkbox',
disabled: false,
readOnly: false,
hidden: false,
classList: { contains() { return false; } },
getBoundingClientRect() { return { width: 14, height: 14 }; },
closest(selector) {
if (selector === 'button, [role="button"], a, label, .el-checkbox, .el-checkbox__input') return this;
if (selector === 'label') return agreementLabel;
if (selector === 'label, div, span, p, li, form') return agreementLabel;
return null;
},
};
const rememberLabel = {
innerText: '30天内免登录',
textContent: '30天内免登录',
hidden: false,
getBoundingClientRect() { return { width: 100, height: 20 }; },
parentElement: null,
};
const agreementLabel = {
innerText: '我已阅读并同意 《服务协议》 和 《隐私政策》',
textContent: '我已阅读并同意 《服务协议》 和 《隐私政策》',
hidden: false,
getBoundingClientRect() { return { width: 220, height: 24 }; },
parentElement: null,
querySelector(selector) {
return selector.includes('checkbox') ? agreementCheckbox : null;
},
};
rememberCheckbox.parentElement = rememberLabel;
agreementCheckbox.parentElement = agreementLabel;
const document = {
querySelectorAll(selector) {
if (selector === 'label, div, span, p, form') {
return [rememberLabel, agreementLabel];
}
if (selector === 'input[type="checkbox"], [role="checkbox"], .ivu-checkbox, .el-checkbox') {
return [rememberCheckbox, agreementCheckbox];
}
return [];
},
};
const window = {
getComputedStyle() {
return { display: 'block', visibility: 'visible' };
},
};
${bundle}
return {
findAgreementCheckbox,
rememberCheckbox,
agreementCheckbox,
};
`)();
assert.equal(api.findAgreementCheckbox(), api.agreementCheckbox);
});
test('ensureAgreementChecked clicks all visible login checkboxes', async () => {
const bundle = [
extractFunction('isVisibleNode'),
extractFunction('resolveActionTarget'),
extractFunction('isCheckboxChecked'),
extractFunction('ensureAgreementChecked'),
].join('\n');
const api = new Function(`
const rememberCheckbox = {
disabled: false,
readOnly: false,
hidden: false,
checked: false,
classList: { contains() { return false; } },
getBoundingClientRect() { return { width: 14, height: 14 }; },
click() { this.checked = true; },
closest(selector) {
if (selector === 'button, [role="button"], a, label, .el-checkbox, .el-checkbox__input') return this;
return null;
},
querySelector() { return null; },
getAttribute(name) {
if (name === 'aria-checked') return this.checked ? 'true' : 'false';
return '';
},
};
const agreementCheckbox = {
disabled: false,
readOnly: false,
hidden: false,
checked: false,
classList: { contains() { return false; } },
getBoundingClientRect() { return { width: 14, height: 14 }; },
click() { this.checked = true; },
closest(selector) {
if (selector === 'button, [role="button"], a, label, .el-checkbox, .el-checkbox__input') return this;
return null;
},
querySelector() { return null; },
getAttribute(name) {
if (name === 'aria-checked') return this.checked ? 'true' : 'false';
return '';
},
};
const document = {
querySelectorAll(selector) {
if (selector === 'input[type="checkbox"], [role="checkbox"], .ivu-checkbox, .el-checkbox') {
return [rememberCheckbox, agreementCheckbox];
}
return [];
},
};
const window = {
getComputedStyle() {
return { display: 'block', visibility: 'visible' };
},
};
async function sleep() {}
function simulateClick(node) {
node.click();
}
${bundle}
return {
rememberCheckbox,
agreementCheckbox,
ensureAgreementChecked,
};
`)();
const result = await api.ensureAgreementChecked();
assert.equal(result, true);
assert.equal(api.rememberCheckbox.checked, true);
assert.equal(api.agreementCheckbox.checked, true);
});
+3 -2
View File
@@ -526,9 +526,10 @@
1. 用户在 sidepanel 的 2925 账号池中保存 `email / password`
2. 只有当 sidepanel 中的 `mail2925UseAccountPool` 开关开启时,别名基邮箱才会优先取当前账号池选中的 2925 账号邮箱;关闭时会回退到原来的手填 `mail2925BaseEmail`
3. 手动点击 `登录` 或自动流程进入 Step 4 / Step 8 前,只有在号池模式开启时,后台才会确保当前 2925 账号已登录网页邮箱
3. 手动点击 `登录` 或自动流程进入 Step 4 / Step 8 前,只有在号池模式开启时,后台才会确保当前 2925 账号已登录网页邮箱;登录动作提交后,内容脚本会在 20 秒内轮询“是否已进入收件箱”
4. 一旦轮询期间出现“子邮箱已达上限邮箱”,后台会先记录当前时间;若当前处于号池模式且还有下一个可用账号,则把当前账号禁用 24 小时并自动切到下一个账号重新登录
5. 如果没有下一个可用账号,或当前未启用号池模式,则不会继续消耗自动重试次数,而是直接复用现有 `requestStop()` 停止链路,把整个自动流程停成和用户手动点击“停止”一致的状态
5. 如果登录提交后 20 秒内仍未进入收件箱,且当前正处于自动运行中,则后台会直接复用现有 `requestStop()` 停止链路,把整个自动流程停成和用户手动点击“停止”一致的状态;这类情况常见于图片验证、行为验证或其他阻断登录的中间页
6. 如果没有下一个可用账号,或当前未启用号池模式,则不会继续消耗自动重试次数,而是直接复用现有 `requestStop()` 停止链路,把整个自动流程停成和用户手动点击“停止”一致的状态
### 7.5 iCloud