diff --git a/background/steps/fetch-login-code.js b/background/steps/fetch-login-code.js index b25ab62..0046dd7 100644 --- a/background/steps/fetch-login-code.js +++ b/background/steps/fetch-login-code.js @@ -51,9 +51,14 @@ }); } + function normalizeStep8VerificationTargetEmail(value) { + return String(value || '').trim().toLowerCase(); + } + async function runStep8Attempt(state) { const mail = getMailConfig(state); if (mail.error) throw new Error(mail.error); + const stepStartedAt = Date.now(); const authTabId = await getTabId('signup-page'); @@ -67,10 +72,25 @@ } throwIfStopped(); - await ensureStep8VerificationPageReady({ + const pageState = await ensureStep8VerificationPageReady({ timeoutMs: await getStep8ReadyTimeoutMs('确认登录验证码页已就绪'), }); + const shouldCompareVerificationEmail = mail.provider !== '2925'; + const displayedVerificationEmail = shouldCompareVerificationEmail + ? normalizeStep8VerificationTargetEmail(pageState?.displayedEmail) + : ''; + const fixedTargetEmail = shouldCompareVerificationEmail + ? (displayedVerificationEmail || normalizeStep8VerificationTargetEmail(state?.email)) + : ''; + + await setState({ + step8VerificationTargetEmail: displayedVerificationEmail || '', + }); + await addLog('步骤 8:登录验证码页面已就绪,开始获取验证码。', 'info'); + if (shouldCompareVerificationEmail && displayedVerificationEmail) { + await addLog(`步骤 8:已固定当前验证码页显示邮箱 ${displayedVerificationEmail} 作为后续匹配目标。`, 'info'); + } if (shouldUseCustomRegistrationEmail(state)) { await confirmCustomVerificationStepBypass(8); @@ -78,7 +98,11 @@ } throwIfStopped(); - if (mail.provider === HOTMAIL_PROVIDER || mail.provider === LUCKMAIL_PROVIDER || mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER) { + if ( + mail.provider === HOTMAIL_PROVIDER + || mail.provider === LUCKMAIL_PROVIDER + || mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER + ) { await addLog(`步骤 8:正在通过 ${mail.label} 轮询验证码...`); } else { await addLog(`步骤 8:正在打开${mail.label}...`); @@ -102,10 +126,14 @@ } } - await resolveVerificationStep(8, state, mail, { + await resolveVerificationStep(8, { + ...state, + step8VerificationTargetEmail: displayedVerificationEmail || '', + }, mail, { filterAfterTimestamp: stepStartedAt, getRemainingTimeMs: getStep8RemainingTimeResolver(), requestFreshCodeFirst: false, + targetEmail: fixedTargetEmail, resendIntervalMs: (mail.provider === HOTMAIL_PROVIDER || mail.provider === '2925') ? 0 : STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS, diff --git a/background/verification-flow.js b/background/verification-flow.js index 055e2e7..bfce3bf 100644 --- a/background/verification-flow.js +++ b/background/verification-flow.js @@ -113,7 +113,7 @@ const is2925Provider = state?.mailProvider === '2925'; if (step === 4) { return { - filterAfterTimestamp: getHotmailVerificationRequestTimestamp(4, state), + filterAfterTimestamp: is2925Provider ? 0 : getHotmailVerificationRequestTimestamp(4, state), senderFilters: ['openai', 'noreply', 'verify', 'auth', 'duckduckgo', 'forward'], subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm'], targetEmail: state.email, @@ -124,10 +124,10 @@ } return { - filterAfterTimestamp: getHotmailVerificationRequestTimestamp(8, state), + filterAfterTimestamp: is2925Provider ? 0 : getHotmailVerificationRequestTimestamp(8, state), senderFilters: ['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'], subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm', 'login'], - targetEmail: state.email, + targetEmail: String(state?.step8VerificationTargetEmail || '').trim() || state.email, maxAttempts: is2925Provider ? MAIL_2925_VERIFICATION_MAX_ATTEMPTS : 5, intervalMs: is2925Provider ? MAIL_2925_VERIFICATION_INTERVAL_MS : 3000, ...overrides, @@ -236,6 +236,33 @@ return requestedAt; } + function triggerPostSuccessMailboxCleanup(step, mail) { + if (mail?.provider !== '2925') { + return; + } + + Promise.resolve().then(async () => { + try { + await sendToMailContentScriptResilient( + mail, + { + type: 'DELETE_ALL_EMAILS', + step, + source: 'background', + payload: {}, + }, + { + timeoutMs: 10000, + responseTimeoutMs: 5000, + maxRecoveryAttempts: 1, + } + ); + } catch (_) { + // Best-effort cleanup only. + } + }); + } + async function pollFreshVerificationCodeWithResendInterval(step, state, mail, pollOverrides = {}) { const stateKey = getVerificationCodeStateKey(step); const rejectedCodes = new Set(); @@ -656,6 +683,7 @@ emailTimestamp: result.emailTimestamp, code: result.code, }); + triggerPostSuccessMailboxCleanup(step, mail); return; } } diff --git a/content/mail-2925.js b/content/mail-2925.js index 81f00e2..0197ce7 100644 --- a/content/mail-2925.js +++ b/content/mail-2925.js @@ -1,4 +1,4 @@ -// content/mail-2925.js — Content script for 2925 Mail (steps 4, 7) +// content/mail-2925.js - Content script for 2925 Mail (steps 4, 8) // Injected dynamically on: 2925.com const MAIL2925_PREFIX = '[MultiPage:mail-2925]'; @@ -37,19 +37,28 @@ async function persistSeenCodes() { chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === 'POLL_EMAIL') { resetStopState(); - handlePollEmail(message.step, message.payload).then(result => { + handlePollEmail(message.step, message.payload).then((result) => { sendResponse(result); - }).catch(err => { + }).catch((err) => { if (isStopError(err)) { log(`步骤 ${message.step}:已被用户停止。`, 'warn'); sendResponse({ stopped: true, error: err.message }); return; } + log(`步骤 ${message.step}:邮箱轮询失败:${err.message}`, 'warn'); sendResponse({ error: err.message }); }); return true; } + + if (message.type === 'DELETE_ALL_EMAILS') { + Promise.resolve(deleteAllMailboxEmails(message.step)).catch(() => {}); + sendResponse({ ok: true }); + return true; + } + + return false; }); const MAIL_ITEM_SELECTORS = [ @@ -64,13 +73,67 @@ const MAIL_ITEM_SELECTORS = [ '[class*="list-item"]', 'li[class*="mail"]', ]; -const MAIL_REFRESH_SELECTOR = '[class*="refresh"], [title*="鍒锋柊"], [aria-label*="鍒锋柊"], [class*="Refresh"]'; +const MAIL_ITEM_SELECTOR_GROUP = MAIL_ITEM_SELECTORS.join(', '); +const MAIL_REFRESH_SELECTORS = [ + '[class*="refresh"]', + '[title*="刷新"]', + '[aria-label*="刷新"]', + '[class*="Refresh"]', +]; const MAIL_INBOX_SELECTORS = [ 'a[href*="mailList"]', '[class*="inbox"]', '[class*="Inbox"]', - '[title*="鏀朵欢绠?]', + '[title*="收件箱"]', ]; +const MAIL_DELETE_SELECTORS = [ + '[class*="delete"]', + '[title*="删除"]', + '[aria-label*="删除"]', + '[class*="Delete"]', +]; +const MAIL_SELECT_ALL_SELECTORS = [ + 'input[type="checkbox"]', + '[role="checkbox"]', + '.el-checkbox__input', + '.el-checkbox', + 'label[class*="checkbox"]', + '[class*="checkbox"]', +]; +const MAIL_ACTION_CANDIDATE_SELECTORS = 'button, [role="button"], a, label, span, div'; + +function normalizeNodeText(value) { + return String(value || '').replace(/\s+/g, ' ').trim(); +} + +function isVisibleNode(node) { + if (!node) return false; + if (node.hidden) return false; + + const style = typeof window.getComputedStyle === 'function' + ? window.getComputedStyle(node) + : null; + if (style && (style.display === 'none' || style.visibility === 'hidden')) { + return false; + } + + const rect = typeof node.getBoundingClientRect === 'function' + ? node.getBoundingClientRect() + : null; + if (rect && rect.width <= 0 && rect.height <= 0) { + return false; + } + + return true; +} + +function isMailItemNode(node) { + return Boolean(node?.closest?.(MAIL_ITEM_SELECTOR_GROUP)); +} + +function resolveActionTarget(node) { + return node?.closest?.('button, [role="button"], a, label, .el-checkbox, .el-checkbox__input') || node || null; +} function findMailItems() { for (const selector of MAIL_ITEM_SELECTORS) { @@ -82,20 +145,81 @@ function findMailItems() { return []; } -function findRefreshButton() { - return document.querySelector(MAIL_REFRESH_SELECTOR); -} - -function findInboxLink() { - for (const selector of MAIL_INBOX_SELECTORS) { - const node = document.querySelector(selector); - if (node) { - return node; +function findActionBySelectors(selectors = []) { + for (const selector of selectors) { + const candidates = document.querySelectorAll(selector); + for (const candidate of candidates) { + const target = resolveActionTarget(candidate); + if (!isVisibleNode(target) || isMailItemNode(target)) { + continue; + } + return target; } } return null; } +function findToolbarActionButton(patterns = [], selectors = []) { + const directMatch = findActionBySelectors(selectors); + if (directMatch) { + return directMatch; + } + + const candidates = document.querySelectorAll(MAIL_ACTION_CANDIDATE_SELECTORS); + for (const candidate of candidates) { + const target = resolveActionTarget(candidate); + if (!isVisibleNode(target) || isMailItemNode(target)) { + continue; + } + + const text = normalizeNodeText(target.innerText || target.textContent || ''); + const label = normalizeNodeText(target.getAttribute?.('aria-label') || target.getAttribute?.('title') || ''); + if (patterns.some((pattern) => pattern.test(text) || pattern.test(label))) { + return target; + } + } + + return null; +} + +function findRefreshButton() { + return findToolbarActionButton([ + /刷新/i, + /refresh/i, + ], MAIL_REFRESH_SELECTORS); +} + +function findInboxLink() { + return findActionBySelectors(MAIL_INBOX_SELECTORS); +} + +function findDeleteButton() { + return findToolbarActionButton([ + /删除/i, + /delete/i, + ], MAIL_DELETE_SELECTORS); +} + +function findSelectAllControl() { + return findActionBySelectors(MAIL_SELECT_ALL_SELECTORS); +} + +function isCheckboxChecked(node) { + const checkbox = node?.matches?.('input[type="checkbox"], [role="checkbox"]') + ? node + : node?.querySelector?.('input[type="checkbox"], [role="checkbox"]'); + if (checkbox?.checked === true) { + return true; + } + if (String(checkbox?.getAttribute?.('aria-checked') || '').toLowerCase() === 'true') { + return true; + } + return Boolean( + node?.classList?.contains('is-checked') + || node?.classList?.contains('checked') + ); +} + function getMailItemText(item) { if (!item) return ''; const contentCell = item.querySelector('td.content, .content, .mail-content'); @@ -112,11 +236,11 @@ function getMailItemText(item) { function getMailItemTimeText(item) { const timeEl = item?.querySelector('.date-time-text, [class*="date-time"], [class*="time"], td.time'); - return (timeEl?.textContent || '').replace(/\s+/g, ' ').trim(); + return normalizeNodeText(timeEl?.textContent || ''); } function normalizeMailIdentityPart(value) { - return (value || '').replace(/\s+/g, ' ').trim().toLowerCase(); + return normalizeNodeText(value).toLowerCase(); } function getMailItemId(item, index = 0) { @@ -148,82 +272,36 @@ function getCurrentMailIds(items = []) { return ids; } -function normalizeMinuteTimestamp(timestamp) { - if (!Number.isFinite(timestamp) || timestamp <= 0) return 0; - const date = new Date(timestamp); - date.setSeconds(0, 0); - return date.getTime(); -} - function matchesMailFilters(text, senderFilters, subjectFilters) { - const lower = (text || '').toLowerCase(); - const senderMatch = senderFilters.some(filter => lower.includes(filter.toLowerCase())); - const subjectMatch = subjectFilters.some(filter => lower.includes(filter.toLowerCase())); + const lower = String(text || '').toLowerCase(); + const senderMatch = senderFilters.some((filter) => lower.includes(String(filter || '').toLowerCase())); + const subjectMatch = subjectFilters.some((filter) => lower.includes(String(filter || '').toLowerCase())); return senderMatch || subjectMatch; } function extractVerificationCode(text, strictChatGPTCodeOnly = false) { if (strictChatGPTCodeOnly) { - const strictMatch = text.match(/your\s+chatgpt\s+code\s+is\s+(\d{6})/i); + const strictMatch = String(text || '').match(/your\s+chatgpt\s+code\s+is\s+(\d{6})/i); return strictMatch ? strictMatch[1] : null; } - const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/); + const normalized = String(text || ''); + + const matchCn = normalized.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/); if (matchCn) return matchCn[1]; - const matchChatGPT = text.match(/your\s+chatgpt\s+code\s+is\s+(\d{6})/i); + const matchChatGPT = normalized.match(/your\s+chatgpt\s+code\s+is\s+(\d{6})/i); if (matchChatGPT) return matchChatGPT[1]; - const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i); + const matchEn = normalized.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i); if (matchEn) return matchEn[1] || matchEn[2]; - const match6 = text.match(/\b(\d{6})\b/); + const match6 = normalized.match(/\b(\d{6})\b/); if (match6) return match6[1]; return null; } -function extractEmails(text) { - const matches = text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/ig) || []; - 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 atIndex = normalizedTarget.indexOf('@'); - if (atIndex > 0) { - const encodedTarget = `${normalizedTarget.slice(0, atIndex)}=${normalizedTarget.slice(atIndex + 1)}`; - if (normalizedText.includes(encodedTarget)) { - return { matches: true, hasExplicitEmail: true }; - } - } - - const emails = extractEmails(text); - if (!emails.length) { - return { matches: false, hasExplicitEmail: false }; - } - - return { - matches: emails.some(email => emailMatchesTarget(email, normalizedTarget)), - hasExplicitEmail: true, - }; -} - function parseMailItemTimestamp(item) { const timeText = getMailItemTimeText(item); if (!timeText) return null; @@ -236,7 +314,7 @@ function parseMailItemTimestamp(item) { return now.getTime(); } - match = timeText.match(/(\d+)\s*分(?:钟)?前/); + match = timeText.match(/(\d+)\s*分钟前/); if (match) { return now.getTime() - Number(match[1]) * 60 * 1000; } @@ -324,19 +402,70 @@ async function openMailAndGetMessageText(item) { } } +async function deleteCurrentMailboxEmail(step) { + try { + const deleteButton = findDeleteButton(); + if (!deleteButton) { + return false; + } + + simulateClick(deleteButton); + await sleepRandom(200, 500); + return true; + } catch (err) { + console.warn(MAIL2925_PREFIX, `Step ${step}: delete-current cleanup failed:`, err?.message || err); + return false; + } +} + +async function openMailAndDeleteAfterRead(item, step) { + simulateClick(item); + try { + await sleepRandom(1200, 2200); + return document.body?.textContent || ''; + } finally { + await deleteCurrentMailboxEmail(step); + await returnToInbox(); + } +} + +async function deleteAllMailboxEmails(step) { + try { + await returnToInbox(); + + const selectAllControl = findSelectAllControl(); + if (!selectAllControl) { + return false; + } + + if (!isCheckboxChecked(selectAllControl)) { + simulateClick(selectAllControl); + await sleepRandom(200, 500); + } + + const deleteButton = findDeleteButton(); + if (!deleteButton) { + return false; + } + + simulateClick(deleteButton); + await sleepRandom(200, 500); + return true; + } catch (err) { + console.warn(MAIL2925_PREFIX, `Step ${step}: delete-all cleanup failed:`, err?.message || err); + return false; + } +} + async function refreshInbox() { - const refreshBtn = document.querySelector( - '[class*="refresh"], [title*="刷新"], [aria-label*="刷新"], [class*="Refresh"]' - ); + const refreshBtn = findRefreshButton(); if (refreshBtn) { simulateClick(refreshBtn); await sleepRandom(700, 1200); return; } - const inboxLink = document.querySelector( - 'a[href*="mailList"], [class*="inbox"], [class*="Inbox"], [title*="收件箱"]' - ); + const inboxLink = findInboxLink(); if (inboxLink) { simulateClick(inboxLink); await sleepRandom(700, 1200); @@ -349,27 +478,26 @@ async function handlePollEmail(step, payload) { subjectFilters, maxAttempts, intervalMs, - filterAfterTimestamp = 0, excludeCodes = [], strictChatGPTCodeOnly = false, - targetEmail = '', - } = payload; + } = payload || {}; const excludedCodeSet = new Set(excludeCodes.filter(Boolean)); - const filterAfterMinute = normalizeMinuteTimestamp(Number(filterAfterTimestamp) || 0); log(`步骤 ${step}:开始轮询 2925 邮箱(最多 ${maxAttempts} 次)`); - if (filterAfterMinute) { - log(`步骤 ${step}:仅尝试 ${new Date(filterAfterMinute).toLocaleString('zh-CN', { hour12: false })} 及之后时间的邮件。`); - } let initialItems = []; - for (let i = 0; i < 20; i++) { + let initialLoadUsedRefresh = false; + + for (let i = 0; i < 20; i += 1) { initialItems = findMailItems(); - if (initialItems.length > 0) break; + if (initialItems.length > 0) { + break; + } await sleep(500); } if (initialItems.length === 0) { + initialLoadUsedRefresh = true; await returnToInbox(); await refreshInbox(); await sleep(2000); @@ -380,16 +508,16 @@ async function handlePollEmail(step, payload) { throw new Error('2925 邮箱列表未加载完成,请确认当前已打开收件箱。'); } - const existingMailIds = getCurrentMailIds(initialItems); + const knownMailIds = getCurrentMailIds(initialItems); log(`步骤 ${step}:邮件列表已加载,共 ${initialItems.length} 封邮件`); - log(`步骤 ${step}:已记录当前 ${existingMailIds.size} 封旧邮件快照`); + log(`步骤 ${step}:已记录当前 ${knownMailIds.size} 封旧邮件快照`); const FALLBACK_AFTER = 3; - for (let attempt = 1; attempt <= maxAttempts; attempt++) { + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { log(`步骤 ${step}:正在轮询 2925 邮箱,第 ${attempt}/${maxAttempts} 次`); - if (attempt > 1) { + if (attempt > 1 || !initialLoadUsedRefresh) { await returnToInbox(); await refreshInbox(); await sleepRandom(900, 1500); @@ -398,78 +526,61 @@ async function handlePollEmail(step, payload) { const items = findMailItems(); if (items.length > 0) { const useFallback = attempt > FALLBACK_AFTER; + const newMailIds = new Set(); - for (let index = 0; index < items.length; index++) { + items.forEach((item, index) => { + const itemId = getMailItemId(item, index); + if (!knownMailIds.has(itemId)) { + newMailIds.add(itemId); + } + }); + + for (let index = 0; index < items.length; index += 1) { const item = items[index]; const itemId = getMailItemId(item, index); + const isNewMail = newMailIds.has(itemId); const itemTimestamp = parseMailItemTimestamp(item); - const itemMinute = normalizeMinuteTimestamp(itemTimestamp || 0); - const passesTimeFilter = !filterAfterMinute || (itemMinute && itemMinute >= filterAfterMinute); - const shouldBypassOldSnapshot = Boolean(filterAfterMinute && passesTimeFilter && itemMinute > 0); - if (!passesTimeFilter) { + if (!useFallback && !isNewMail) { continue; } - if (!useFallback && !shouldBypassOldSnapshot && existingMailIds.has(itemId)) { + const previewText = getMailItemText(item); + if (!matchesMailFilters(previewText, senderFilters, subjectFilters)) { continue; } - const text = getMailItemText(item); - if (!matchesMailFilters(text, senderFilters, subjectFilters)) { - continue; - } - - const previewEmails = extractEmails(text); - const previewTargetState = getTargetEmailMatchState(text, targetEmail); - const previewMatchesTarget = previewTargetState.matches; - if (targetEmail && previewEmails.length > 0 && !previewMatchesTarget) { - continue; - } - - const code = extractVerificationCode(text, strictChatGPTCodeOnly); - if (code && previewMatchesTarget) { - if (excludedCodeSet.has(code)) { - log(`步骤 ${step}:跳过排除的验证码:${code}`, 'info'); - continue; - } - if (seenCodes.has(code)) { - log(`步骤 ${step}:跳过已处理过的验证码:${code}`, 'info'); - continue; - } - seenCodes.add(code); - persistSeenCodes(); - const source = useFallback && existingMailIds.has(itemId) ? '回退匹配邮件' : '新邮件'; - const timeLabel = itemTimestamp ? `,时间:${new Date(itemTimestamp).toLocaleString('zh-CN', { hour12: false })}` : ''; - log(`步骤 ${step}:已找到验证码:${code}(来源:${source}${timeLabel})`, 'ok'); - await sleep(1000); - return { ok: true, code, emailTimestamp: Date.now() }; - } - - const openedText = await openMailAndGetMessageText(item); + const previewCode = extractVerificationCode(previewText, strictChatGPTCodeOnly); + const openedText = await openMailAndDeleteAfterRead(item, step); const bodyCode = extractVerificationCode(openedText, strictChatGPTCodeOnly); - const openedTargetState = getTargetEmailMatchState(openedText, targetEmail); - if (targetEmail && openedTargetState.hasExplicitEmail && !openedTargetState.matches) { + const candidateCode = bodyCode || previewCode; + + if (!candidateCode) { continue; } - if (bodyCode) { - if (excludedCodeSet.has(bodyCode)) { - log(`步骤 ${step}:跳过排除的验证码:${bodyCode}`, 'info'); - continue; - } - if (seenCodes.has(bodyCode)) { - log(`步骤 ${step}:跳过已处理过的验证码:${bodyCode}`, 'info'); - continue; - } - seenCodes.add(bodyCode); - persistSeenCodes(); - const source = useFallback && existingMailIds.has(itemId) ? '回退匹配邮件正文' : '新邮件正文'; - const timeLabel = itemTimestamp ? `,时间:${new Date(itemTimestamp).toLocaleString('zh-CN', { hour12: false })}` : ''; - log(`步骤 ${step}:已在邮件正文中找到验证码:${bodyCode}(来源:${source}${timeLabel})`, 'ok'); - await sleep(1000); - return { ok: true, code: bodyCode, emailTimestamp: Date.now() }; + + if (excludedCodeSet.has(candidateCode)) { + log(`步骤 ${step}:跳过排除的验证码:${candidateCode}`, 'info'); + continue; } + if (seenCodes.has(candidateCode)) { + log(`步骤 ${step}:跳过已处理过的验证码:${candidateCode}`, 'info'); + continue; + } + + seenCodes.add(candidateCode); + persistSeenCodes(); + const source = useFallback && !isNewMail + ? (bodyCode ? '回退匹配邮件正文' : '回退匹配邮件') + : (bodyCode ? '新邮件正文' : '新邮件'); + const timeLabel = itemTimestamp ? `,时间:${new Date(itemTimestamp).toLocaleString('zh-CN', { hour12: false })}` : ''; + log(`步骤 ${step}:已找到验证码:${candidateCode}(来源:${source}${timeLabel})`, 'ok'); + return { ok: true, code: candidateCode, emailTimestamp: Date.now() }; } + + items.forEach((item, index) => { + knownMailIds.add(getMailItemId(item, index)); + }); } if (attempt === FALLBACK_AFTER + 1) { diff --git a/content/signup-page.js b/content/signup-page.js index 47000f5..d3b700a 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -695,6 +695,12 @@ function getPageTextSnapshot() { .trim(); } +function getLoginVerificationDisplayedEmail() { + const pageText = getPageTextSnapshot(); + const matches = pageText.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/ig) || []; + return matches[0] ? String(matches[0]).trim().toLowerCase() : ''; +} + function getOAuthConsentForm() { return document.querySelector(OAUTH_CONSENT_FORM_SELECTOR); } @@ -1076,6 +1082,7 @@ function inspectLoginAuthState() { state: 'unknown', url: location.href, path: location.pathname || '', + displayedEmail: getLoginVerificationDisplayedEmail(), retryButton: retryState?.retryButton || null, retryEnabled: Boolean(retryState?.retryEnabled), titleMatched: Boolean(retryState?.titleMatched), @@ -1141,6 +1148,7 @@ function serializeLoginAuthState(snapshot) { state: snapshot?.state || 'unknown', url: snapshot?.url || location.href, path: snapshot?.path || location.pathname || '', + displayedEmail: snapshot?.displayedEmail || '', retryEnabled: Boolean(snapshot?.retryEnabled), titleMatched: Boolean(snapshot?.titleMatched), detailMatched: Boolean(snapshot?.detailMatched), diff --git a/tests/background-step7-recovery.test.js b/tests/background-step7-recovery.test.js index 75b39fe..e906140 100644 --- a/tests/background-step7-recovery.test.js +++ b/tests/background-step7-recovery.test.js @@ -13,6 +13,7 @@ test('step 8 submits login verification directly without replaying step 7', asyn executeStep7: 0, sleep: [], resolveOptions: null, + setStates: [], }; const realDateNow = Date.now; Date.now = () => 123456; @@ -29,7 +30,7 @@ test('step 8 submits login verification directly without replaying step 7', asyn ensureStep8VerificationPageReady: async (options) => { calls.ensureReady += 1; calls.ensureReadyOptions.push(options || null); - return { state: 'verification_page' }; + return { state: 'verification_page', displayedEmail: 'display.user@example.com' }; }, executeStep7: async () => { calls.executeStep7 += 1; @@ -53,7 +54,9 @@ test('step 8 submits login verification directly without replaying step 7', asyn calls.resolveOptions = options; }, reuseOrCreateTab: async () => {}, - setState: async () => {}, + setState: async (payload) => { + calls.setStates.push(payload); + }, setStepStatus: async () => {}, shouldUseCustomRegistrationEmail: () => false, sleepWithStop: async (ms) => { @@ -82,6 +85,10 @@ test('step 8 submits login verification directly without replaying step 7', asyn assert.equal(typeof calls.resolveOptions.getRemainingTimeMs, 'function'); assert.equal(await calls.resolveOptions.getRemainingTimeMs({ actionLabel: '登录验证码流程' }), 5000); assert.equal(calls.resolveOptions.resendIntervalMs, 25000); + assert.equal(calls.resolveOptions.targetEmail, 'display.user@example.com'); + assert.deepStrictEqual(calls.setStates, [ + { step8VerificationTargetEmail: 'display.user@example.com' }, + ]); assert.deepStrictEqual(calls.ensureReadyOptions, [ { timeoutMs: 5000 }, ]); @@ -136,10 +143,62 @@ test('step 8 disables resend interval for 2925 mailbox polling', async () => { }); assert.equal(capturedOptions.resendIntervalMs, 0); + assert.equal(capturedOptions.targetEmail, ''); assert.equal(capturedOptions.beforeSubmit, undefined); assert.equal(typeof capturedOptions.getRemainingTimeMs, 'function'); }); +test('step 8 falls back to the run email when the verification page does not expose a displayed email', async () => { + let capturedOptions = null; + + const executor = api.createStep8Executor({ + addLog: async () => {}, + chrome: { + tabs: { + update: async () => {}, + }, + }, + CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', + confirmCustomVerificationStepBypass: async () => {}, + ensureStep8VerificationPageReady: async () => ({ state: 'verification_page', displayedEmail: '' }), + executeStep7: async () => {}, + getOAuthFlowRemainingMs: async () => 8000, + getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 8000), + getMailConfig: () => ({ + provider: 'qq', + label: 'QQ 邮箱', + source: 'mail-qq', + url: 'https://mail.qq.com', + navigateOnReuse: false, + }), + getState: async () => ({ email: 'user@example.com', password: 'secret' }), + getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2), + HOTMAIL_PROVIDER: 'hotmail-api', + isTabAlive: async () => true, + isVerificationMailPollingError: () => false, + LUCKMAIL_PROVIDER: 'luckmail-api', + resolveVerificationStep: async (_step, _state, _mail, options) => { + capturedOptions = options; + }, + reuseOrCreateTab: async () => {}, + setState: async () => {}, + setStepStatus: async () => {}, + shouldUseCustomRegistrationEmail: () => false, + sleepWithStop: async () => {}, + STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000, + STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8, + throwIfStopped: () => {}, + }); + + await executor.executeStep8({ + email: 'user@example.com', + password: 'secret', + oauthUrl: 'https://oauth.example/latest', + }); + + assert.equal(capturedOptions.targetEmail, 'user@example.com'); +}); + test('step 8 does not rerun step 7 when verification submit lands on add-phone', async () => { const calls = { executeStep7: 0, diff --git a/tests/mail-2925-content.test.js b/tests/mail-2925-content.test.js index cb31656..ad54311 100644 --- a/tests/mail-2925-content.test.js +++ b/tests/mail-2925-content.test.js @@ -51,37 +51,38 @@ function extractFunction(name) { return source.slice(start, end); } -test('handlePollEmail returns to inbox before initial refresh when 2925 opens on a detail page', async () => { +test('handlePollEmail establishes a baseline after opening from detail view and only picks mail from a later refresh', async () => { const bundle = extractFunction('handlePollEmail'); const api = new Function(` -let detailMode = true; +let state = 'detail'; +let refreshCalls = 0; const clickOrder = []; +const readAndDeleteCalls = []; const seenCodes = new Set(); -const mailItem = { text: 'OpenAI verification code 654321' }; +const baselineMail = { id: 'baseline', text: 'OpenAI newsletter without code' }; +const newMail = { id: 'new', text: 'OpenAI verification code 654321' }; function findMailItems() { - return detailMode ? [] : [mailItem]; + if (state === 'detail') return []; + if (state === 'baseline') return [baselineMail]; + return [baselineMail, newMail]; } -function getMailItemId() { - return 'mail-1'; +function getMailItemId(item) { + return item.id; } function getCurrentMailIds(items = []) { - return new Set(items.map(() => 'mail-1')); -} - -function normalizeMinuteTimestamp(value) { - return Number(value) || 0; + return new Set(items.map((item) => item.id)); } function parseMailItemTimestamp() { return Date.now(); } -function matchesMailFilters() { - return true; +function matchesMailFilters(text) { + return /openai|verification/i.test(String(text || '')); } function getMailItemText(item) { @@ -93,29 +94,26 @@ function extractVerificationCode(text) { return match ? match[1] : null; } -function extractEmails() { - return []; -} - -function emailMatchesTarget() { - return true; -} - -function getTargetEmailMatchState() { - return { matches: true, hasExplicitEmail: false }; -} - async function sleep() {} async function sleepRandom() {} async function returnToInbox() { clickOrder.push('inbox'); - detailMode = false; + state = 'baseline'; return true; } async function refreshInbox() { clickOrder.push('refresh'); + refreshCalls += 1; + if (refreshCalls >= 2) { + state = 'with-new'; + } +} + +async function openMailAndDeleteAfterRead(item) { + readAndDeleteCalls.push(item.id); + return item.id === 'new' ? 'Your ChatGPT code is 654321' : 'No code here'; } function persistSeenCodes() {} @@ -128,38 +126,113 @@ return { getClickOrder() { return clickOrder.slice(); }, + getReadAndDeleteCalls() { + return readAndDeleteCalls.slice(); + }, }; `)(); const result = await api.handlePollEmail(4, { senderFilters: ['openai'], subjectFilters: ['verification'], - maxAttempts: 1, + maxAttempts: 2, intervalMs: 1, - filterAfterTimestamp: Date.now(), }); assert.equal(result.code, '654321'); - assert.deepEqual(api.getClickOrder(), ['inbox', 'refresh']); + assert.deepEqual(api.getClickOrder(), ['inbox', 'refresh', 'inbox', 'refresh']); + assert.deepEqual(api.getReadAndDeleteCalls(), ['new']); +}); + +test('handlePollEmail ignores targetEmail and still tests any matching ChatGPT mail', async () => { + const bundle = extractFunction('handlePollEmail'); + + const api = new Function(` +let state = 'empty'; +const seenCodes = new Set(); +const readAndDeleteCalls = []; +const matchingMail = { + id: 'mail-1', + text: 'ChatGPT verification code 112233 for another.user@example.com', +}; + +function findMailItems() { + return state === 'ready' ? [matchingMail] : []; +} + +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() { + state = 'ready'; +} + +async function openMailAndDeleteAfterRead(item) { + readAndDeleteCalls.push(item.id); + return item.text; +} + +function persistSeenCodes() {} +function log() {} + +${bundle} + +return { + handlePollEmail, + getReadAndDeleteCalls() { + return readAndDeleteCalls.slice(); + }, +}; +`)(); + + const result = await api.handlePollEmail(8, { + senderFilters: ['chatgpt'], + subjectFilters: ['verification'], + maxAttempts: 4, + intervalMs: 1, + targetEmail: 'expected@example.com', + }); + + assert.equal(result.code, '112233'); + assert.deepEqual(api.getReadAndDeleteCalls(), ['mail-1']); }); test('openMailAndGetMessageText always returns to inbox after opening a 2925 message', async () => { const bundle = [ - extractFunction('findInboxLink'), extractFunction('returnToInbox'), extractFunction('openMailAndGetMessageText'), ].join('\n'); const api = new Function(` -const MAIL_INBOX_SELECTORS = [ - 'a[href*="mailList"]', - '[class*="inbox"]', - '[class*="Inbox"]', - '[title*="鏀朵欢绠?]', -]; const clickOrder = []; const mailItem = { kind: 'mail' }; -const inboxLink = { kind: 'inbox' }; let listVisible = true; let bodyText = ''; @@ -169,18 +242,16 @@ const document = { return bodyText; }, }, - querySelector(selector) { - if (selector.includes('mailList') || selector.includes('inbox') || selector.includes('Inbox')) { - return inboxLink; - } - return null; - }, }; function findMailItems() { return listVisible ? [mailItem] : []; } +function findInboxLink() { + return { kind: 'inbox' }; +} + function simulateClick(node) { if (node === mailItem) { clickOrder.push('mail'); @@ -188,12 +259,8 @@ function simulateClick(node) { bodyText = 'Your ChatGPT code is 731091'; return; } - if (node === inboxLink) { - clickOrder.push('inbox'); - listVisible = true; - return; - } - throw new Error('unexpected node'); + clickOrder.push('inbox'); + listVisible = true; } async function sleep() {} @@ -219,3 +286,132 @@ return { assert.deepEqual(api.getClickOrder(), ['mail', 'inbox']); assert.equal(api.isListVisible(), true); }); + +test('openMailAndDeleteAfterRead deletes the opened message before returning to inbox', async () => { + const bundle = [ + extractFunction('deleteCurrentMailboxEmail'), + extractFunction('returnToInbox'), + extractFunction('openMailAndDeleteAfterRead'), + ].join('\n'); + + const api = new Function(` +const calls = []; +const mailItem = { kind: 'mail' }; +const deleteButton = { kind: 'delete' }; +let listVisible = true; +let bodyText = ''; + +const document = { + body: { + get textContent() { + return bodyText; + }, + }, +}; + +function findMailItems() { + return listVisible ? [mailItem] : []; +} + +function findDeleteButton() { + return deleteButton; +} + +function findInboxLink() { + return { kind: 'inbox' }; +} + +function simulateClick(node) { + if (node === mailItem) { + calls.push('mail'); + listVisible = false; + bodyText = 'Your ChatGPT code is 778899'; + return; + } + if (node === deleteButton) { + calls.push('delete'); + return; + } + calls.push('inbox'); + listVisible = true; +} + +async function sleep() {} +async function sleepRandom() {} +const console = { warn() {} }; +const MAIL2925_PREFIX = '[MultiPage:mail-2925]'; + +${bundle} + +return { + mailItem, + openMailAndDeleteAfterRead, + getCalls() { + return calls.slice(); + }, +}; +`)(); + + const text = await api.openMailAndDeleteAfterRead(api.mailItem, 8); + + assert.match(text, /778899/); + assert.deepEqual(api.getCalls(), ['mail', 'delete', 'inbox']); +}); + +test('deleteAllMailboxEmails selects all messages and clicks delete', async () => { + const bundle = extractFunction('deleteAllMailboxEmails'); + + const api = new Function(` +const calls = []; +const selectAllControl = { kind: 'select-all' }; +const deleteButton = { kind: 'delete' }; + +async function returnToInbox() { + calls.push('inbox'); + return true; +} + +function findSelectAllControl() { + return selectAllControl; +} + +function isCheckboxChecked() { + return false; +} + +function findDeleteButton() { + return deleteButton; +} + +function simulateClick(node) { + if (node === selectAllControl) { + calls.push('select-all'); + return; + } + if (node === deleteButton) { + calls.push('delete'); + return; + } + throw new Error('unexpected node'); +} + +async function sleepRandom() {} + +const console = { warn() {} }; +const MAIL2925_PREFIX = '[MultiPage:mail-2925]'; + +${bundle} + +return { + deleteAllMailboxEmails, + getCalls() { + return calls.slice(); + }, +}; +`)(); + + const result = await api.deleteAllMailboxEmails(4); + + assert.equal(result, true); + assert.deepEqual(api.getCalls(), ['inbox', 'select-all', 'delete']); +}); diff --git a/tests/step6-login-state.test.js b/tests/step6-login-state.test.js index 119ca13..324f07f 100644 --- a/tests/step6-login-state.test.js +++ b/tests/step6-login-state.test.js @@ -51,6 +51,8 @@ function extractFunction(name) { } const bundle = [ + extractFunction('getPageTextSnapshot'), + extractFunction('getLoginVerificationDisplayedEmail'), extractFunction('inspectLoginAuthState'), extractFunction('normalizeStep6Snapshot'), ].join('\n'); @@ -62,6 +64,13 @@ const location = { pathname: ${JSON.stringify(overrides.pathname || '/log-in')}, }; +const document = { + body: { + innerText: ${JSON.stringify(overrides.pageText || '')}, + textContent: ${JSON.stringify(overrides.pageText || '')}, + }, +}; + function getLoginTimeoutErrorPageState() { return ${JSON.stringify(overrides.retryState || null)}; } @@ -127,6 +136,16 @@ return { ); } +{ + const api = createApi({ + verificationTarget: { id: 'otp' }, + pageText: 'We emailed a code to display.user@example.com. Enter it below.', + }); + + const snapshot = api.inspectLoginAuthState(); + assert.strictEqual(snapshot.displayedEmail, 'display.user@example.com'); +} + { const api = createApi({ oauthConsentPage: true, diff --git a/tests/verification-flow-polling.test.js b/tests/verification-flow-polling.test.js index 4769263..60ee929 100644 --- a/tests/verification-flow-polling.test.js +++ b/tests/verification-flow-polling.test.js @@ -37,8 +37,10 @@ test('verification flow extends 2925 polling window', () => { const step4Payload = helpers.getVerificationPollPayload(4, { email: 'user@example.com', mailProvider: '2925' }); const step8Payload = helpers.getVerificationPollPayload(8, { email: 'user@example.com', mailProvider: '2925' }); + assert.equal(step4Payload.filterAfterTimestamp, 0); assert.equal(step4Payload.maxAttempts, 15); assert.equal(step4Payload.intervalMs, 15000); + assert.equal(step8Payload.filterAfterTimestamp, 0); assert.equal(step8Payload.maxAttempts, 15); assert.equal(step8Payload.intervalMs, 15000); }); @@ -109,6 +111,67 @@ test('verification flow runs beforeSubmit hook before filling the code', async ( ]); }); +test('verification flow triggers 2925 mailbox cleanup only after code submission succeeds', async () => { + const mailMessages = []; + + const helpers = api.createVerificationFlowHelpers({ + addLog: async () => {}, + chrome: { + tabs: { + update: async () => {}, + }, + }, + CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email', + completeStepFromBackground: async () => {}, + confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }), + getHotmailVerificationPollConfig: () => ({}), + getHotmailVerificationRequestTimestamp: () => 0, + getState: async () => ({}), + getTabId: async () => 1, + HOTMAIL_PROVIDER: 'hotmail-api', + isStopError: () => false, + LUCKMAIL_PROVIDER: 'luckmail-api', + MAIL_2925_VERIFICATION_INTERVAL_MS: 15000, + MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15, + pollCloudflareTempEmailVerificationCode: async () => ({}), + pollHotmailVerificationCode: async () => ({}), + pollLuckmailVerificationCode: async () => ({}), + sendToContentScript: async (_source, message) => { + if (message.type === 'FILL_CODE') { + return {}; + } + return {}; + }, + sendToMailContentScriptResilient: async (_mail, message) => { + mailMessages.push(message.type); + if (message.type === 'POLL_EMAIL') { + return { code: '654321', emailTimestamp: 123 }; + } + return { ok: true }; + }, + setState: async () => {}, + setStepStatus: async () => {}, + sleepWithStop: async () => {}, + throwIfStopped: () => {}, + VERIFICATION_POLL_MAX_ROUNDS: 5, + }); + + await helpers.resolveVerificationStep( + 8, + { + email: 'user@example.com', + mailProvider: '2925', + lastLoginCode: null, + }, + { provider: '2925', label: '2925 邮箱' }, + {} + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.deepStrictEqual(mailMessages, ['POLL_EMAIL', 'DELETE_ALL_EMAILS']); +}); + test('verification flow treats add-phone after login code submit as fatal instead of completing step 8', async () => { const events = [];