diff --git a/README.md b/README.md index 85d3a23..6a677d9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 一个用于批量跑通 ChatGPT OAuth 注册/登录流程的 Chrome 扩展。 -当前版本基于侧边栏控制,支持单步执行、整套自动执行、停止当前流程、保存常用配置,以及通过 DuckDuckGo / QQ / 163 / Inbucket mailbox 协助获取验证码。 +当前版本基于侧边栏控制,支持单步执行、整套自动执行、停止当前流程、保存常用配置,以及通过 DuckDuckGo / QQ / 163 / Inbucket / Hotmail API 协助获取验证码。 ## 当前能力 @@ -12,6 +12,7 @@ - 支持自定义密码;留空时自动生成强密码 - 自动显示当前使用中的密码,便于后续保存 - 自动获取注册验证码与登录验证码 +- 支持 `Hotmail API`:直接使用 `email + client_id + refresh_token` 请求第三方接口读取最新邮件 - 支持 `QQ Mail`、`163 Mail`、`Inbucket mailbox` - 支持从 DuckDuckGo Email Protection 自动生成新的 `@duck.com` 地址 - Step 5 同时兼容两种页面: @@ -54,17 +55,37 @@ Step 1 和 Step 9 都依赖这个地址。 ### `Mail` -支持三种验证码来源: +支持四种验证码来源: +- `Hotmail API` - `163 Mail` - `QQ Mail` - `Inbucket` 说明: +- `Hotmail API` 通过侧边栏里的 Hotmail 账号池选择账号,并请求 `https://apple.882263.xyz/api/mail-new` - `QQ` 和 `163` 用于直接轮询网页邮箱 - `Inbucket` 通过你在侧边栏里配置的 host 访问 `mailbox` 页面:`https:///m//` +### `Hotmail 账号池` + +仅当 `Mail = Hotmail API` 时使用。 + +每条账号支持保存: + +- `email` +- `clientId` +- `refreshToken` +- 可选邮箱密码备注 + +使用方式: + +- 先新增账号 +- 点击 `校验` +- 校验通过后,可点击 `测试收信` +- Auto 模式每轮会自动选用一个可用账号 + ### `Mailbox` 仅当 `Mail = Inbucket` 时显示。 @@ -109,6 +130,7 @@ Step 3 使用的注册邮箱。 注意: +- 当 `Mail = Hotmail API` 时,这个输入框由账号池自动同步当前账号邮箱 - 当前 `Auto` 按钮只负责 DuckDuckGo 地址获取 - 如果你使用 Inbucket,它只是验证码收件箱,不会自动生成 Inbucket 地址 @@ -156,9 +178,10 @@ Step 3 使用的注册邮箱。 1. Step 1 获取 CPA OAuth 链接 2. Step 2 打开 OpenAI 注册页 -3. 尝试自动获取 Duck 邮箱 -4. 如果 Duck 自动获取失败,暂停并等待你在侧边栏填写邮箱后点击 `Continue` -5. 继续执行 Step 3 ~ Step 9 +3. 根据 `Mail` 选择邮箱来源 +4. `Hotmail API` 会从账号池自动分配一个可用账号 +5. Duck 模式下会优先尝试自动获取邮箱;失败时暂停等待手动填写 +6. 继续执行 Step 3 ~ Step 9 也就是说: @@ -207,6 +230,7 @@ Step 3 使用的注册邮箱。 支持: +- `Hotmail API`(第三方最新邮件接口) - `content/qq-mail.js` - `content/mail-163.js` - `content/inbucket-mail.js` @@ -318,6 +342,7 @@ Step 3 使用的注册邮箱。 - 邮箱服务 - Inbucket 主机 - Inbucket 邮箱名 +- Hotmail 账号池与对应 token - 兜底开关 特点: @@ -337,6 +362,7 @@ data/names.js 随机姓名、生日数据 content/utils.js 通用工具:等待元素、点击、日志、停止控制 content/vps-panel.js CPA 面板步骤:Step 1 / Step 9 content/signup-page.js OpenAI 注册/登录页步骤:Step 2 / 3 / 5 / 6 / 8 +hotmail-utils.js Hotmail API 相关通用 helper content/duck-mail.js Duck 邮箱自动获取 content/qq-mail.js QQ 邮箱验证码轮询 content/mail-163.js 163 邮箱验证码轮询 diff --git a/background.js b/background.js index 673f9f4..c2db506 100644 --- a/background.js +++ b/background.js @@ -1,9 +1,30 @@ // background.js — Service Worker: orchestration, state, tab management, message routing -importScripts('data/names.js'); +importScripts('data/names.js', 'hotmail-utils.js', 'content/activation-utils.js'); + +const { + buildHotmailMailApiLatestUrl, + extractVerificationCodeFromMessage, + filterHotmailAccountsByUsage, + getLatestHotmailMessage, + getHotmailMailApiRequestConfig, + getHotmailVerificationPollConfig, + getHotmailVerificationRequestTimestamp, + normalizeHotmailMailApiMessages, + pickHotmailAccountForRun, + pickVerificationMessage, + pickVerificationMessageWithFallback, + pickVerificationMessageWithTimeFallback, + shouldClearHotmailCurrentSelection, +} = self.HotmailUtils; +const { + isRecoverableStep9AuthFailure, +} = self.MultiPageActivationUtils; const LOG_PREFIX = '[MultiPage:bg]'; const DUCK_AUTOFILL_URL = 'https://duckduckgo.com/email/settings/autofill'; +const HOTMAIL_PROVIDER = 'hotmail-api'; +const HOTMAIL_MAILBOXES = ['INBOX', 'Junk']; const STOP_ERROR_MESSAGE = '流程已被用户停止。'; const HUMAN_STEP_DELAY_MIN = 700; const HUMAN_STEP_DELAY_MAX = 2200; @@ -23,6 +44,7 @@ const PERSISTED_SETTING_DEFAULTS = { mailProvider: '163', inbucketHost: '', inbucketMailbox: '', + hotmailAccounts: [], }; const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS); @@ -38,10 +60,13 @@ const DEFAULT_STATE = { password: null, accounts: [], // { email, password, createdAt } lastEmailTimestamp: null, + signupVerificationRequestedAt: null, + loginVerificationRequestedAt: null, lastSignupCode: null, lastLoginCode: null, localhostUrl: null, flowStartTime: null, + currentHotmailAccountId: null, tabRegistry: {}, sourceLastUrls: {}, logs: [], @@ -59,6 +84,7 @@ async function getPersistedSettings() { ...PERSISTED_SETTING_DEFAULTS, ...stored, autoRunSkipFailures: Boolean(stored.autoRunSkipFailures ?? PERSISTED_SETTING_DEFAULTS.autoRunSkipFailures), + hotmailAccounts: normalizeHotmailAccounts(stored.hotmailAccounts), }; } @@ -92,9 +118,13 @@ async function setPersistentSettings(updates) { const persistedUpdates = {}; for (const key of PERSISTED_SETTING_KEYS) { if (updates[key] !== undefined) { - persistedUpdates[key] = key === 'autoRunSkipFailures' - ? Boolean(updates[key]) - : updates[key]; + if (key === 'autoRunSkipFailures') { + persistedUpdates[key] = Boolean(updates[key]); + } else if (key === 'hotmailAccounts') { + persistedUpdates[key] = normalizeHotmailAccounts(updates[key]); + } else { + persistedUpdates[key] = updates[key]; + } } } @@ -174,6 +204,408 @@ function generatePassword() { return pw.split('').sort(() => Math.random() - 0.5).join(''); } +function normalizeHotmailAccount(account = {}) { + return { + id: String(account.id || crypto.randomUUID()), + email: String(account.email || '').trim(), + password: String(account.password || ''), + clientId: String(account.clientId || '').trim(), + accessToken: String(account.accessToken || ''), + refreshToken: String(account.refreshToken || ''), + expiresAt: Number.isFinite(Number(account.expiresAt)) ? Number(account.expiresAt) : 0, + status: String(account.status || (account.refreshToken ? 'authorized' : 'pending')), + enabled: account.enabled !== undefined ? Boolean(account.enabled) : true, + used: Boolean(account.used), + lastUsedAt: Number.isFinite(Number(account.lastUsedAt)) ? Number(account.lastUsedAt) : 0, + lastAuthAt: Number.isFinite(Number(account.lastAuthAt)) ? Number(account.lastAuthAt) : 0, + lastError: String(account.lastError || ''), + }; +} + +function normalizeHotmailAccounts(accounts) { + if (!Array.isArray(accounts)) return []; + + const deduped = new Map(); + for (const account of accounts) { + const normalized = normalizeHotmailAccount(account); + if (!normalized.email && !normalized.id) continue; + deduped.set(normalized.id, normalized); + } + return [...deduped.values()]; +} + +function findHotmailAccount(accounts, accountId) { + return normalizeHotmailAccounts(accounts).find((account) => account.id === accountId) || null; +} + +function isHotmailProvider(stateOrProvider) { + const provider = typeof stateOrProvider === 'string' + ? stateOrProvider + : stateOrProvider?.mailProvider; + return provider === HOTMAIL_PROVIDER; +} + +async function syncHotmailAccounts(accounts) { + const normalized = normalizeHotmailAccounts(accounts); + await setPersistentSettings({ hotmailAccounts: normalized }); + await setState({ hotmailAccounts: normalized }); + broadcastDataUpdate({ hotmailAccounts: normalized }); + return normalized; +} + +async function upsertHotmailAccount(input) { + const state = await getState(); + const accounts = normalizeHotmailAccounts(state.hotmailAccounts); + const normalizedEmail = String(input?.email || '').trim().toLowerCase(); + const existing = input?.id + ? findHotmailAccount(accounts, input.id) + : accounts.find((account) => account.email.toLowerCase() === normalizedEmail) || null; + const normalized = normalizeHotmailAccount({ + ...(existing || {}), + ...input, + id: input?.id || existing?.id || crypto.randomUUID(), + }); + + const nextAccounts = existing + ? accounts.map((account) => (account.id === normalized.id ? normalized : account)) + : [...accounts, normalized]; + + await syncHotmailAccounts(nextAccounts); + return normalized; +} + +async function deleteHotmailAccount(accountId) { + const state = await getState(); + const accounts = normalizeHotmailAccounts(state.hotmailAccounts); + const nextAccounts = accounts.filter((account) => account.id !== accountId); + await syncHotmailAccounts(nextAccounts); + + if (state.currentHotmailAccountId === accountId) { + await setState({ currentHotmailAccountId: null }); + if (isHotmailProvider(state)) { + await setEmailState(null); + } + broadcastDataUpdate({ currentHotmailAccountId: null }); + } +} + +async function deleteHotmailAccounts(mode = 'all') { + const state = await getState(); + const accounts = normalizeHotmailAccounts(state.hotmailAccounts); + const targets = filterHotmailAccountsByUsage(accounts, mode); + const targetIds = new Set(targets.map((account) => account.id)); + const nextAccounts = mode === 'used' + ? accounts.filter((account) => !targetIds.has(account.id)) + : []; + + await syncHotmailAccounts(nextAccounts); + + if (state.currentHotmailAccountId && targetIds.has(state.currentHotmailAccountId)) { + await setState({ currentHotmailAccountId: null }); + if (isHotmailProvider(state)) { + await setEmailState(null); + } + broadcastDataUpdate({ currentHotmailAccountId: null }); + } + + return { + deletedCount: targets.length, + remainingCount: nextAccounts.length, + }; +} + +async function patchHotmailAccount(accountId, updates = {}) { + const state = await getState(); + const accounts = normalizeHotmailAccounts(state.hotmailAccounts); + const account = findHotmailAccount(accounts, accountId); + if (!account) { + throw new Error('未找到对应的 Hotmail 账号。'); + } + + const nextAccount = normalizeHotmailAccount({ + ...account, + ...updates, + id: account.id, + }); + + await syncHotmailAccounts(accounts.map((item) => (item.id === account.id ? nextAccount : item))); + + if (state.currentHotmailAccountId === account.id && shouldClearHotmailCurrentSelection(nextAccount)) { + await setState({ currentHotmailAccountId: null }); + broadcastDataUpdate({ currentHotmailAccountId: null }); + if (isHotmailProvider(state)) { + await setEmailState(null); + } + } + + return nextAccount; +} + +async function setCurrentHotmailAccount(accountId, options = {}) { + const { markUsed = false, syncEmail = true } = options; + const state = await getState(); + const accounts = normalizeHotmailAccounts(state.hotmailAccounts); + const account = findHotmailAccount(accounts, accountId); + if (!account) { + throw new Error('未找到对应的 Hotmail 账号。'); + } + + if (markUsed) { + account.lastUsedAt = Date.now(); + await syncHotmailAccounts(accounts.map((item) => (item.id === account.id ? account : item))); + } + + await setState({ currentHotmailAccountId: account.id }); + broadcastDataUpdate({ currentHotmailAccountId: account.id }); + if (syncEmail) { + await setEmailState(account.email || null); + } + return account; +} + +async function ensureHotmailAccountForFlow(options = {}) { + const { allowAllocate = true, markUsed = false, preferredAccountId = null } = options; + const state = await getState(); + const accounts = normalizeHotmailAccounts(state.hotmailAccounts); + const isAccountAllocatable = (candidate) => Boolean(candidate) + && candidate.status === 'authorized' + && !candidate.used + && Boolean(candidate.refreshToken); + + let account = null; + if (preferredAccountId) { + account = findHotmailAccount(accounts, preferredAccountId); + } + if (!account && state.currentHotmailAccountId) { + account = findHotmailAccount(accounts, state.currentHotmailAccountId); + } + if ((!account || !isAccountAllocatable(account)) && allowAllocate) { + account = pickHotmailAccountForRun(accounts, {}); + } + + if (!account) { + throw new Error('没有可用的 Hotmail 账号。请先在侧边栏添加至少一个带 refresh token 的账号。'); + } + if (!isAccountAllocatable(account)) { + throw new Error(`Hotmail 账号 ${account.email || account.id} 尚未就绪,无法读取邮件。`); + } + + return setCurrentHotmailAccount(account.id, { markUsed, syncEmail: true }); +} + +async function requestHotmailMailApi(account, mailbox = 'INBOX') { + if (!account?.email) { + throw new Error('Hotmail 账号缺少邮箱地址。'); + } + if (!account?.clientId) { + throw new Error(`Hotmail 账号 ${account.email || account.id} 缺少 client ID。`); + } + if (!account?.refreshToken) { + throw new Error(`Hotmail 账号 ${account.email || account.id} 缺少 refresh token。`); + } + + const url = buildHotmailMailApiLatestUrl({ + clientId: account.clientId, + email: account.email, + refreshToken: account.refreshToken, + mailbox, + responseType: 'json', + }); + const { timeoutMs } = getHotmailMailApiRequestConfig(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(new Error('timeout')), timeoutMs); + + let response; + try { + response = await fetch(url, { method: 'GET', signal: controller.signal }); + } catch (err) { + if (err?.name === 'AbortError') { + throw new Error(`Hotmail API 请求超时(>${Math.round(timeoutMs / 1000)} 秒):${mailbox}`); + } + throw new Error(`Hotmail API 请求失败:${err.message}`); + } finally { + clearTimeout(timeoutId); + } + + const text = await response.text(); + let payload = {}; + try { + payload = text ? JSON.parse(text) : {}; + } catch { + payload = { raw: text }; + } + + if (!response.ok) { + const errorText = payload?.message || payload?.error || payload?.msg || text || `HTTP ${response.status}`; + throw new Error(`Hotmail API 请求失败:${errorText}`); + } + + if (payload && payload.success === false) { + const errorText = payload?.message || payload?.msg || payload?.error || '未知错误'; + throw new Error(`Hotmail API 返回失败:${errorText}`); + } + + return { + mailbox, + payload, + messages: normalizeHotmailMailApiMessages(payload?.data), + nextRefreshToken: String(payload?.new_refresh_token || payload?.newRefreshToken || '').trim(), + }; +} + +function applyHotmailApiResultToAccount(account, apiResult) { + const nextRefreshToken = String(apiResult?.nextRefreshToken || '').trim(); + return { + ...account, + accessToken: '', + expiresAt: 0, + refreshToken: nextRefreshToken || account.refreshToken, + status: 'authorized', + lastAuthAt: Date.now(), + lastError: '', + }; +} + +async function fetchHotmailMailboxMessages(account, mailboxes = HOTMAIL_MAILBOXES) { + let workingAccount = normalizeHotmailAccount(account); + const mailboxResults = []; + + for (const mailbox of mailboxes) { + const result = await requestHotmailMailApi(workingAccount, mailbox); + workingAccount = applyHotmailApiResultToAccount(workingAccount, result); + mailboxResults.push({ + mailbox, + count: result.messages.length, + messages: result.messages.map((message) => ({ ...message, mailbox })), + }); + } + + const savedAccount = await upsertHotmailAccount(workingAccount); + return { + account: savedAccount, + mailboxResults, + messages: mailboxResults.flatMap((item) => item.messages), + }; +} + +async function verifyHotmailAccount(accountId) { + const state = await getState(); + const account = findHotmailAccount(state.hotmailAccounts, accountId); + if (!account) { + throw new Error('未找到需要校验的 Hotmail 账号。'); + } + + const result = await fetchHotmailMailboxMessages(account, ['INBOX']); + return { + account: result.account, + messageCount: result.mailboxResults[0]?.count || 0, + }; +} + +async function testHotmailAccountMailAccess(accountId) { + const state = await getState(); + const account = findHotmailAccount(state.hotmailAccounts, accountId); + if (!account) { + throw new Error('未找到需要测试的 Hotmail 账号。'); + } + + const result = await fetchHotmailMailboxMessages(account, HOTMAIL_MAILBOXES); + const latestMessage = getLatestHotmailMessage(result.messages); + const latestCode = latestMessage ? extractVerificationCodeFromMessage(latestMessage) : null; + + return { + account: result.account, + accountId: result.account.id, + email: result.account.email, + messageCount: result.messages.length, + latestSubject: latestMessage?.subject || '', + latestMailbox: latestMessage?.mailbox || '', + latestCode: latestCode || '', + inboxCount: result.mailboxResults.find((item) => item.mailbox === 'INBOX')?.count || 0, + junkCount: result.mailboxResults.find((item) => item.mailbox === 'Junk')?.count || 0, + }; +} + +async function pollHotmailVerificationCode(step, state, pollPayload = {}) { + await addLog(`步骤 ${step}:正在确定 Hotmail 收信账号...`, 'info'); + let account = await ensureHotmailAccountForFlow({ + allowAllocate: true, + markUsed: false, + preferredAccountId: state.currentHotmailAccountId || null, + }); + await addLog(`步骤 ${step}:当前使用 Hotmail 账号 ${account.email} 轮询收件箱。`, 'info'); + + const maxAttempts = Number(pollPayload.maxAttempts) || 5; + const intervalMs = Number(pollPayload.intervalMs) || 3000; + let lastError = null; + + function summarizeMessagesForLog(messages) { + return (messages || []) + .slice() + .sort((left, right) => { + const leftTime = Date.parse(left.receivedDateTime || '') || 0; + const rightTime = Date.parse(right.receivedDateTime || '') || 0; + return rightTime - leftTime; + }) + .slice(0, 3) + .map((message) => { + const receivedAt = message?.receivedDateTime || 'unknown-time'; + const sender = message?.from?.emailAddress?.address || 'unknown'; + const subject = message?.subject || '(no subject)'; + const preview = String(message?.bodyPreview || '').replace(/\s+/g, ' ').trim().slice(0, 80); + return `[${message.mailbox || 'INBOX'}] ${receivedAt} | ${sender} | ${subject} | ${preview}`; + }) + .join(' || '); + } + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + throwIfStopped(); + try { + await addLog(`步骤 ${step}:正在轮询 Hotmail 邮件(${attempt}/${maxAttempts})...`, 'info'); + const fetchResult = await fetchHotmailMailboxMessages(account, HOTMAIL_MAILBOXES); + account = fetchResult.account; + const matchResult = pickVerificationMessageWithTimeFallback(fetchResult.messages, { + afterTimestamp: pollPayload.filterAfterTimestamp || 0, + senderFilters: pollPayload.senderFilters || [], + subjectFilters: pollPayload.subjectFilters || [], + excludeCodes: pollPayload.excludeCodes || [], + }); + const match = matchResult.match; + + if (match?.code) { + const mailboxLabel = match.message?.mailbox || 'INBOX'; + if (matchResult.usedRelaxedFilters) { + const fallbackLabel = matchResult.usedTimeFallback ? '宽松匹配 + 时间回退' : '宽松匹配'; + await addLog(`步骤 ${step}:严格规则未命中,已改用 ${fallbackLabel} 并命中 Hotmail ${mailboxLabel} 验证码。`, 'warn'); + } + await addLog(`步骤 ${step}:已在 Hotmail ${mailboxLabel} 中找到验证码:${match.code}`, 'ok'); + return { + ok: true, + code: match.code, + emailTimestamp: match.receivedAt || Date.now(), + mailId: match.message?.id || '', + }; + } + + lastError = new Error(`步骤 ${step}:暂未在 Hotmail 收件箱中找到匹配验证码(${attempt}/${maxAttempts})。`); + await addLog(lastError.message, attempt === maxAttempts ? 'warn' : 'info'); + const mailSummary = summarizeMessagesForLog(fetchResult.messages); + if (mailSummary) { + await addLog(`步骤 ${step}:最近邮件样本:${mailSummary}`, 'info'); + } + } catch (err) { + lastError = err; + await addLog(`步骤 ${step}:Hotmail 收件箱轮询失败:${err.message}`, 'warn'); + } + + if (attempt < maxAttempts) { + await sleepWithStop(intervalMs); + } + } + + throw lastError || new Error(`步骤 ${step}:未在 Hotmail 收件箱中找到新的匹配验证码。`); +} + // ============================================================ // Tab Registry // ============================================================ @@ -614,6 +1046,7 @@ function getSourceLabel(source) { 'mail-163': '163 邮箱', 'inbucket-mail': 'Inbucket 邮箱', 'duck-mail': 'Duck 邮箱', + 'hotmail-api': 'Hotmail API', }; return labels[source] || source || '未知来源'; } @@ -650,7 +1083,7 @@ function getErrorMessage(error) { function isVerificationMailPollingError(error) { const message = getErrorMessage(error); - return /未在 .*邮箱中找到新的匹配邮件|邮箱轮询结束,但未获取到验证码|无法获取新的(?:注册|登录)验证码|页面未能重新就绪|页面通信异常|did not respond in \d+s/i.test(message); + return /未在 .*邮箱中找到新的匹配邮件|未在 Hotmail 收件箱中找到新的匹配验证码|邮箱轮询结束,但未获取到验证码|无法获取新的(?:注册|登录)验证码|页面未能重新就绪|页面通信异常|did not respond in \d+s/i.test(message); } function isRestartCurrentAttemptError(error) { @@ -658,7 +1091,13 @@ function isRestartCurrentAttemptError(error) { return /当前邮箱已存在,需要重新开始新一轮/.test(message); } -function isStep9OAuthTimeoutError(error) { +function isStep9RecoverableAuthError(error) { + const message = String(typeof error === 'string' ? error : error?.message || ''); + return /STEP9_OAUTH_RETRY::/i.test(message) + || isRecoverableStep9AuthFailure(message); +} + +function isLegacyStep9RecoverableAuthError(error) { const message = String(typeof error === 'string' ? error : error?.message || ''); return /STEP9_OAUTH_TIMEOUT::|认证失败:\s*Timeout waiting for OAuth callback/i.test(message); } @@ -687,6 +1126,8 @@ function getDownstreamStateResets(step) { flowStartTime: null, password: null, lastEmailTimestamp: null, + signupVerificationRequestedAt: null, + loginVerificationRequestedAt: null, lastSignupCode: null, lastLoginCode: null, localhostUrl: null, @@ -696,6 +1137,8 @@ function getDownstreamStateResets(step) { return { password: null, lastEmailTimestamp: null, + signupVerificationRequestedAt: null, + loginVerificationRequestedAt: null, lastSignupCode: null, lastLoginCode: null, localhostUrl: null, @@ -704,6 +1147,8 @@ function getDownstreamStateResets(step) { if (step === 3 || step === 4) { return { lastEmailTimestamp: null, + signupVerificationRequestedAt: null, + loginVerificationRequestedAt: null, lastSignupCode: null, lastLoginCode: null, localhostUrl: null, @@ -712,6 +1157,7 @@ function getDownstreamStateResets(step) { if (step === 5 || step === 6 || step === 7) { return { lastLoginCode: null, + loginVerificationRequestedAt: null, localhostUrl: null, }; } @@ -1067,6 +1513,63 @@ async function handleMessage(message, sender) { return { ok: true }; } + case 'UPSERT_HOTMAIL_ACCOUNT': { + const account = await upsertHotmailAccount(message.payload || {}); + return { ok: true, account }; + } + + case 'DELETE_HOTMAIL_ACCOUNT': { + await deleteHotmailAccount(String(message.payload?.accountId || '')); + return { ok: true }; + } + + case 'DELETE_HOTMAIL_ACCOUNTS': { + const result = await deleteHotmailAccounts(String(message.payload?.mode || 'all')); + return { ok: true, ...result }; + } + + case 'SELECT_HOTMAIL_ACCOUNT': { + const account = await setCurrentHotmailAccount(String(message.payload?.accountId || ''), { + markUsed: false, + syncEmail: true, + }); + return { ok: true, account }; + } + + case 'PATCH_HOTMAIL_ACCOUNT': { + const account = await patchHotmailAccount( + String(message.payload?.accountId || ''), + message.payload?.updates || {} + ); + return { ok: true, account }; + } + + case 'VERIFY_HOTMAIL_ACCOUNT': + case 'AUTHORIZE_HOTMAIL_ACCOUNT': { + const accountId = String(message.payload?.accountId || ''); + try { + const result = await verifyHotmailAccount(accountId); + await setCurrentHotmailAccount(result.account.id, { markUsed: false, syncEmail: true }); + await addLog(`Hotmail 账号 ${result.account.email} 校验通过,可直接用于收信。`, 'ok'); + return { ok: true, account: result.account, messageCount: result.messageCount }; + } catch (err) { + const state = await getState(); + const accounts = normalizeHotmailAccounts(state.hotmailAccounts); + const target = findHotmailAccount(accounts, accountId); + if (target) { + target.status = 'error'; + target.lastError = err.message; + await syncHotmailAccounts(accounts.map((item) => (item.id === target.id ? target : item))); + } + throw err; + } + } + + case 'TEST_HOTMAIL_ACCOUNT': { + const result = await testHotmailAccountMailAccess(String(message.payload?.accountId || '')); + return { ok: true, ...result }; + } + // Side panel data updates case 'SAVE_EMAIL': { const state = await getState(); @@ -1114,9 +1617,29 @@ async function handleStepData(step, payload) { break; case 3: if (payload.email) await setEmailState(payload.email); + if (payload.signupVerificationRequestedAt) { + await setState({ signupVerificationRequestedAt: payload.signupVerificationRequestedAt }); + } + if (payload.loginVerificationRequestedAt) { + await setState({ loginVerificationRequestedAt: payload.loginVerificationRequestedAt }); + } + break; + case 6: + if (payload.loginVerificationRequestedAt) { + await setState({ loginVerificationRequestedAt: payload.loginVerificationRequestedAt }); + } break; case 4: - if (payload.emailTimestamp) await setState({ lastEmailTimestamp: payload.emailTimestamp }); + await setState({ + lastEmailTimestamp: payload.emailTimestamp || null, + signupVerificationRequestedAt: null, + }); + break; + case 7: + await setState({ + lastEmailTimestamp: payload.emailTimestamp || null, + loginVerificationRequestedAt: null, + }); break; case 8: if (payload.localhostUrl) { @@ -1125,6 +1648,14 @@ async function handleStepData(step, payload) { } break; case 9: { + const latestState = await getState(); + if (latestState.currentHotmailAccountId && isHotmailProvider(latestState)) { + await patchHotmailAccount(latestState.currentHotmailAccountId, { + used: true, + lastUsedAt: Date.now(), + }); + await addLog('当前 Hotmail 账号已自动标记为已用。', 'ok'); + } const localhostPrefix = buildLocalhostCleanupPrefix(payload.localhostUrl); if (localhostPrefix) { await closeTabsByUrlPrefix(localhostPrefix); @@ -1352,6 +1883,16 @@ async function resumeAutoRunIfWaitingForEmail(options = {}) { async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) { const currentState = await getState(); + if (isHotmailProvider(currentState)) { + const account = await ensureHotmailAccountForFlow({ + allowAllocate: true, + markUsed: true, + preferredAccountId: null, + }); + await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:已分配 Hotmail 账号 ${account.email}(第 ${attemptRuns} 次尝试)===`, 'ok'); + return account.email; + } + if (currentState.email) { return currentState.email; } @@ -1430,14 +1971,23 @@ async function runAutoSequenceFromStep(startStep, context = {}) { await executeStepAndWait(step, AUTO_STEP_DELAYS[step]); step += 1; } catch (err) { - if (step === 9 && isStep9OAuthTimeoutError(err) && step9RestartAttempts < maxStep9RestartAttempts) { + const latestState = await getState(); + const currentMail = getMailConfig(latestState); + const shouldRetryStep9 = step === 9 + && ( + isLegacyStep9RecoverableAuthError(err) + || (currentMail.provider === HOTMAIL_PROVIDER && isStep9RecoverableAuthError(err)) + ) + && step9RestartAttempts < maxStep9RestartAttempts; + + if (shouldRetryStep9) { step9RestartAttempts += 1; await addLog( - `步骤 9:检测到 OAuth callback 超时,正在回到步骤 6 重新开始授权流程(${step9RestartAttempts}/${maxStep9RestartAttempts})...`, + `步骤 9:检测到 CPA 认证失败,正在回到步骤 6 重新开始授权流程(${step9RestartAttempts}/${maxStep9RestartAttempts})...`, 'warn' ); await invalidateDownstreamAfterStepRestart(6, { - logLabel: `步骤 9 超时后准备回到步骤 6 重试(${step9RestartAttempts}/${maxStep9RestartAttempts})`, + logLabel: `步骤 9 认证失败后准备回到步骤 6 重试(${step9RestartAttempts}/${maxStep9RestartAttempts})`, }); step = 6; continue; @@ -1696,7 +2246,7 @@ async function executeStep1(state) { } await addLog('步骤 1:正在打开 CPA 面板...'); await reuseOrCreateTab('vps-panel', state.vpsUrl, { - inject: ['content/utils.js', 'content/vps-panel.js'], + inject: ['content/activation-utils.js', 'content/utils.js', 'content/vps-panel.js'], reloadIfSameUrl: true, }); @@ -1732,26 +2282,39 @@ async function executeStep2(state) { // ============================================================ async function executeStep3(state) { - if (!state.email) { + let resolvedEmail = state.email; + if (isHotmailProvider(state)) { + const account = await ensureHotmailAccountForFlow({ + allowAllocate: true, + markUsed: true, + preferredAccountId: state.currentHotmailAccountId || null, + }); + resolvedEmail = account.email; + } + + if (!resolvedEmail) { throw new Error('缺少邮箱地址,请先在侧边栏粘贴邮箱。'); } const password = state.customPassword || generatePassword(); + if (resolvedEmail !== state.email) { + await setEmailState(resolvedEmail); + } await setPasswordState(password); // Save account record const accounts = state.accounts || []; - accounts.push({ email: state.email, password, createdAt: new Date().toISOString() }); + accounts.push({ email: resolvedEmail, password, createdAt: new Date().toISOString() }); await setState({ accounts }); await addLog( - `步骤 3:正在填写邮箱 ${state.email},密码为${state.customPassword ? '自定义' : '自动生成'}(${password.length} 位)` + `步骤 3:正在填写邮箱 ${resolvedEmail},密码为${state.customPassword ? '自定义' : '自动生成'}(${password.length} 位)` ); await sendToContentScript('signup-page', { type: 'EXECUTE_STEP', step: 3, source: 'background', - payload: { email: state.email, password }, + payload: { email: resolvedEmail, password }, }); } @@ -1761,6 +2324,9 @@ async function executeStep3(state) { function getMailConfig(state) { const provider = state.mailProvider || 'qq'; + if (provider === HOTMAIL_PROVIDER) { + return { provider: HOTMAIL_PROVIDER, label: 'Hotmail API' }; + } if (provider === '163') { return { source: 'mail-163', url: 'https://mail.163.com/js6/main.jsp?df=mail163_letter#module=mbox.ListModule%7C%7B%22fid%22%3A1%2C%22order%22%3A%22date%22%2C%22desc%22%3Atrue%7D', label: '163 邮箱' }; } @@ -1778,7 +2344,7 @@ function getMailConfig(state) { url: `${host}/m/${encodeURIComponent(mailbox)}/`, label: `Inbucket 邮箱(${mailbox})`, navigateOnReuse: true, - inject: ['content/utils.js', 'content/inbucket-mail.js'], + inject: ['content/activation-utils.js', 'content/utils.js', 'content/inbucket-mail.js'], injectSource: 'inbucket-mail', }; } @@ -1810,7 +2376,7 @@ function getVerificationCodeLabel(step) { function getVerificationPollPayload(step, state, overrides = {}) { if (step === 4) { return { - filterAfterTimestamp: state.flowStartTime || 0, + filterAfterTimestamp: getHotmailVerificationRequestTimestamp(4, state), senderFilters: ['openai', 'noreply', 'verify', 'auth', 'duckduckgo', 'forward'], subjectFilters: ['verify', 'verification', 'code', '楠岃瘉', 'confirm'], targetEmail: state.email, @@ -1821,7 +2387,7 @@ function getVerificationPollPayload(step, state, overrides = {}) { } return { - filterAfterTimestamp: state.lastEmailTimestamp || state.flowStartTime || 0, + filterAfterTimestamp: getHotmailVerificationRequestTimestamp(7, state), senderFilters: ['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'], subjectFilters: ['verify', 'verification', 'code', '楠岃瘉', 'confirm', 'login'], targetEmail: state.email, @@ -1855,6 +2421,15 @@ async function requestVerificationCodeResend(step) { } async function pollFreshVerificationCode(step, state, mail, pollOverrides = {}) { + if (mail.provider === HOTMAIL_PROVIDER) { + const hotmailPollConfig = getHotmailVerificationPollConfig(step); + return pollHotmailVerificationCode(step, state, { + ...getVerificationPollPayload(step, state), + ...hotmailPollConfig, + ...pollOverrides, + }); + } + const stateKey = getVerificationCodeStateKey(step); const rejectedCodes = new Set(); if (state[stateKey]) { @@ -1943,12 +2518,18 @@ async function submitVerificationCode(step, code) { async function resolveVerificationStep(step, state, mail, options = {}) { const stateKey = getVerificationCodeStateKey(step); const rejectedCodes = new Set(); - if (state[stateKey]) { + const hotmailPollConfig = mail.provider === HOTMAIL_PROVIDER + ? getHotmailVerificationPollConfig(step) + : null; + const ignorePersistedLastCode = Boolean(hotmailPollConfig?.ignorePersistedLastCode); + if (state[stateKey] && !ignorePersistedLastCode) { rejectedCodes.add(state[stateKey]); } const nextFilterAfterTimestamp = options.filterAfterTimestamp ?? null; - const requestFreshCodeFirst = Boolean(options.requestFreshCodeFirst); + const requestFreshCodeFirst = options.requestFreshCodeFirst !== undefined + ? Boolean(options.requestFreshCodeFirst) + : (hotmailPollConfig?.requestFreshCodeFirst ?? false); const maxSubmitAttempts = 3; if (requestFreshCodeFirst) { @@ -1960,6 +2541,14 @@ async function resolveVerificationStep(step, state, mail, options = {}) { } } + if (mail.provider === HOTMAIL_PROVIDER) { + const initialDelayMs = Number(options.initialDelayMs ?? hotmailPollConfig.initialDelayMs) || 0; + if (initialDelayMs > 0) { + await addLog(`步骤 ${step}:等待 ${Math.round(initialDelayMs / 1000)} 秒,让 Hotmail 验证码邮件先到达...`, 'info'); + await sleepWithStop(initialDelayMs); + } + } + for (let attempt = 1; attempt <= maxSubmitAttempts; attempt++) { const result = await pollFreshVerificationCode(step, state, mail, { excludeCodes: [...rejectedCodes], @@ -2024,35 +2613,42 @@ async function executeStep4(state) { if (prepareResult && prepareResult.error) { throw new Error(prepareResult.error); } + if (prepareResult?.verificationRequestedAt) { + await setState({ loginVerificationRequestedAt: prepareResult.verificationRequestedAt }); + } if (prepareResult?.alreadyVerified) { await completeStepFromBackground(4, {}); return; } - await addLog(`步骤 4:正在打开${mail.label}...`); + if (mail.provider === HOTMAIL_PROVIDER) { + await addLog(`步骤 4:正在通过 ${mail.label} 轮询验证码...`); + } else { + await addLog(`步骤 4:正在打开${mail.label}...`); - // For mail tabs, only create if not alive — don't navigate (preserves login session) - const alive = await isTabAlive(mail.source); - if (alive) { - if (mail.navigateOnReuse) { + // For mail tabs, only create if not alive — don't navigate (preserves login session) + const alive = await isTabAlive(mail.source); + if (alive) { + if (mail.navigateOnReuse) { + await reuseOrCreateTab(mail.source, mail.url, { + inject: mail.inject, + injectSource: mail.injectSource, + }); + } else { + const tabId = await getTabId(mail.source); + await chrome.tabs.update(tabId, { active: true }); + } + } else { await reuseOrCreateTab(mail.source, mail.url, { inject: mail.inject, injectSource: mail.injectSource, }); - } else { - const tabId = await getTabId(mail.source); - await chrome.tabs.update(tabId, { active: true }); } - } else { - await reuseOrCreateTab(mail.source, mail.url, { - inject: mail.inject, - injectSource: mail.injectSource, - }); } await resolveVerificationStep(4, state, mail, { - filterAfterTimestamp: stepStartedAt, - requestFreshCodeFirst: true, + filterAfterTimestamp: mail.provider === HOTMAIL_PROVIDER ? undefined : stepStartedAt, + requestFreshCodeFirst: mail.provider === HOTMAIL_PROVIDER ? false : true, }); return; } @@ -2148,29 +2744,33 @@ async function runStep7Attempt(state) { throw new Error(prepareResult.error); } - await addLog(`步骤 7:正在打开${mail.label}...`); + if (mail.provider === HOTMAIL_PROVIDER) { + await addLog(`步骤 7:正在通过 ${mail.label} 轮询验证码...`); + } else { + await addLog(`步骤 7:正在打开${mail.label}...`); - const alive = await isTabAlive(mail.source); - if (alive) { - if (mail.navigateOnReuse) { + const alive = await isTabAlive(mail.source); + if (alive) { + if (mail.navigateOnReuse) { + await reuseOrCreateTab(mail.source, mail.url, { + inject: mail.inject, + injectSource: mail.injectSource, + }); + } else { + const tabId = await getTabId(mail.source); + await chrome.tabs.update(tabId, { active: true }); + } + } else { await reuseOrCreateTab(mail.source, mail.url, { inject: mail.inject, injectSource: mail.injectSource, }); - } else { - const tabId = await getTabId(mail.source); - await chrome.tabs.update(tabId, { active: true }); } - } else { - await reuseOrCreateTab(mail.source, mail.url, { - inject: mail.inject, - injectSource: mail.injectSource, - }); } await resolveVerificationStep(7, state, mail, { - filterAfterTimestamp: stepStartedAt, - requestFreshCodeFirst: true, + filterAfterTimestamp: mail.provider === HOTMAIL_PROVIDER ? undefined : stepStartedAt, + requestFreshCodeFirst: mail.provider === HOTMAIL_PROVIDER ? false : true, }); } @@ -2345,7 +2945,7 @@ async function executeStep9(state) { // Inject scripts directly and wait for them to be ready await chrome.scripting.executeScript({ target: { tabId }, - files: ['content/utils.js', 'content/vps-panel.js'], + files: ['content/activation-utils.js', 'content/utils.js', 'content/vps-panel.js'], }); await new Promise(r => setTimeout(r, 1000)); diff --git a/content/activation-utils.js b/content/activation-utils.js new file mode 100644 index 0000000..f614c31 --- /dev/null +++ b/content/activation-utils.js @@ -0,0 +1,48 @@ +(function activationUtilsModule(root, factory) { + if (typeof module !== 'undefined' && module.exports) { + module.exports = factory(); + return; + } + + root.MultiPageActivationUtils = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createActivationUtils() { + function normalizeTagName(tagName) { + return String(tagName || '').trim().toLowerCase(); + } + + function normalizeType(type) { + return String(type || '').trim().toLowerCase(); + } + + function normalizePathname(pathname) { + return String(pathname || '').trim().toLowerCase(); + } + + function getActivationStrategy(target = {}) { + const tagName = normalizeTagName(target.tagName); + const type = normalizeType(target.type); + const pathname = normalizePathname(target.pathname); + const hasForm = Boolean(target.hasForm); + const isEmailVerificationRoute = /\/email-verification(?:[/?#]|$)/i.test(pathname); + const isSubmitButton = hasForm + && ( + (tagName === 'button' && (!type || type === 'submit')) + || (tagName === 'input' && type === 'submit') + ); + + if (isSubmitButton && isEmailVerificationRoute) { + return { method: 'requestSubmit' }; + } + + return { method: 'click' }; + } + + function isRecoverableStep9AuthFailure(statusText) { + return /认证失败:\s*/i.test(String(statusText || '').trim()); + } + + return { + getActivationStrategy, + isRecoverableStep9AuthFailure, + }; +}); diff --git a/content/signup-page.js b/content/signup-page.js index 7fe1f0d..8fb1736 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -215,9 +215,10 @@ async function prepareLoginCodeFlow(timeout = 15000) { loggedPasswordPage = false; log('步骤 7:检测到密码页,正在切换到一次性验证码登录...'); await humanPause(350, 900); + const verificationRequestedAt = Date.now(); simulateClick(switchTrigger); await sleep(1200); - continue; + return { ready: true, mode: 'verification_switch', verificationRequestedAt }; } if (passwordInput && !loggedPasswordPage) { @@ -351,15 +352,16 @@ async function step3_fillEmailPassword(payload) { fillInput(passwordInput, payload.password); log('步骤 3:密码已填写'); - // Report complete BEFORE submit, because submit causes page navigation - // which kills the content script connection - reportComplete(3, { email }); - - // Submit the form (page will navigate away after this) - await sleep(500); const submitBtn = document.querySelector('button[type="submit"]') || await waitForElementByText('button', /continue|sign\s*up|submit|注册|创建|create/i, 5000).catch(() => null); + // Report complete BEFORE submit, because submit causes page navigation + // which kills the content script connection + const signupVerificationRequestedAt = submitBtn ? Date.now() : null; + reportComplete(3, { email, signupVerificationRequestedAt }); + + // Submit the form (page will navigate away after this) + await sleep(500); if (submitBtn) { await humanPause(500, 1300); simulateClick(submitBtn); diff --git a/content/utils.js b/content/utils.js index a80ffca..b687ff8 100644 --- a/content/utils.js +++ b/content/utils.js @@ -1,5 +1,7 @@ // content/utils.js — Shared utilities for all content scripts +const getActivationStrategy = self.MultiPageActivationUtils?.getActivationStrategy; + const SCRIPT_SOURCE = (() => { if (window.__MULTIPAGE_SOURCE) return window.__MULTIPAGE_SOURCE; const url = location.href; @@ -292,9 +294,34 @@ function reportError(step, errorMessage) { */ function simulateClick(el) { throwIfStopped(); - el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); - console.log(LOG_PREFIX, `已点击: ${el.tagName} ${el.textContent?.slice(0, 30) || ''}`); - log(`已点击 [${el.tagName}] "${el.textContent?.trim().slice(0, 30) || ''}"`); + if (!el) { + throw new Error('无法点击空元素。'); + } + + const form = el.form || el.closest?.('form') || null; + const strategy = typeof getActivationStrategy === 'function' + ? getActivationStrategy({ + tagName: el.tagName, + type: el.getAttribute?.('type') || el.type || '', + hasForm: Boolean(form), + pathname: location.pathname || '', + }) + : { method: 'click' }; + + let method = strategy.method || 'click'; + + if (method === 'requestSubmit' && form && typeof form.requestSubmit === 'function') { + form.requestSubmit(el); + } else if (typeof el.click === 'function') { + method = 'click'; + el.click(); + } else { + method = 'dispatchEvent'; + el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + } + + console.log(LOG_PREFIX, `已点击(${method}): ${el.tagName} ${el.textContent?.slice(0, 30) || ''}`); + log(`已点击(${method}) [${el.tagName}] "${el.textContent?.trim().slice(0, 30) || ''}"`); } /** diff --git a/content/vps-panel.js b/content/vps-panel.js index 1c7b47c..3f2af3d 100644 --- a/content/vps-panel.js +++ b/content/vps-panel.js @@ -25,6 +25,10 @@ console.log('[MultiPage:vps-panel] Content script loaded on', location.href); +const { + isRecoverableStep9AuthFailure, +} = self.MultiPageActivationUtils || {}; + // Listen for commands from Background chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === 'EXECUTE_STEP') { @@ -111,6 +115,12 @@ async function waitForExactSuccessBadge(timeout = 30000) { if (statusText === '认证成功!') { return statusText; } + if (isOAuthCallbackTimeoutFailure(statusText)) { + throw new Error(`STEP9_OAUTH_TIMEOUT::${statusText}`); + } + if (typeof isRecoverableStep9AuthFailure === 'function' && isRecoverableStep9AuthFailure(statusText)) { + throw new Error(`STEP9_OAUTH_RETRY::${statusText}`); + } await sleep(200); } @@ -118,6 +128,9 @@ async function waitForExactSuccessBadge(timeout = 30000) { if (isOAuthCallbackTimeoutFailure(finalText)) { throw new Error(`STEP9_OAUTH_TIMEOUT::${finalText}`); } + if (typeof isRecoverableStep9AuthFailure === 'function' && isRecoverableStep9AuthFailure(finalText)) { + throw new Error(`STEP9_OAUTH_RETRY::${finalText}`); + } throw new Error(finalText ? `CPA 面板状态不是“认证成功!”,当前为“${finalText}”。` : 'CPA 面板长时间未出现“认证成功!”状态徽标。'); diff --git a/docs/superpowers/plans/2026-04-10-hotmail-oauth-mail-pool.md b/docs/superpowers/plans/2026-04-10-hotmail-oauth-mail-pool.md new file mode 100644 index 0000000..820ba69 --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-hotmail-oauth-mail-pool.md @@ -0,0 +1,271 @@ +# Hotmail OAuth Mail Pool Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a Hotmail account pool with Microsoft OAuth authorization and Microsoft Graph mail polling, then wire it into the existing automation flow as a new mail provider. + +**Architecture:** Keep the existing 1~9 step orchestrator in `background.js`, add a new `hotmail-api` provider path, and extend the side panel to manage Hotmail accounts. Preserve QQ, 163, and Inbucket behavior while introducing account allocation, token management, and Graph-based verification-code retrieval. + +**Tech Stack:** Chrome Extension MV3, plain JavaScript, `chrome.identity.launchWebAuthFlow`, `fetch`, `chrome.storage.local`, `chrome.storage.session` + +--- + +### Task 1: Extend State Model for Hotmail Accounts + +**Files:** +- Modify: `background.js` +- Modify: `README.md` + +- [ ] **Step 1: Write the failing test** + +Document the expected account shape and provider behavior in code comments or development notes before implementation: + +```js +// Expected local storage shape: +// hotmailAccounts: [{ id, email, password, clientId, accessToken, refreshToken, expiresAt, status, lastUsedAt, lastAuthAt, lastError }] +// mailProvider accepts 'hotmail-api' +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run a manual smoke check by loading the current extension and confirming there is no `hotmail-api` provider and no persisted Hotmail account state. + +Expected: The provider does not exist yet and account state is absent. + +- [ ] **Step 3: Write minimal implementation** + +Add new persisted keys and runtime state helpers in `background.js`: + +- `hotmailAccounts` +- `currentHotmailAccountId` +- helper functions to read, write, upsert, delete, and mark account status + +- [ ] **Step 4: Run test to verify it passes** + +Reload the extension and confirm Hotmail account state can be read and written through background message handlers. + +- [ ] **Step 5: Commit** + +```bash +git add background.js README.md +git commit -m "feat: add hotmail account state model" +``` + +### Task 2: Add Hotmail Account Pool UI + +**Files:** +- Modify: `sidepanel/sidepanel.html` +- Modify: `sidepanel/sidepanel.css` +- Modify: `sidepanel/sidepanel.js` + +- [ ] **Step 1: Write the failing test** + +Describe the expected UI state: + +- provider selector includes `hotmail-api` +- Hotmail account section renders a list +- user can add, authorize, test, and delete accounts + +- [ ] **Step 2: Run test to verify it fails** + +Reload the current extension. + +Expected: No Hotmail provider option and no account section. + +- [ ] **Step 3: Write minimal implementation** + +Add: + +- a new provider option +- a Hotmail accounts management section +- side panel event handlers for add, authorize, test, delete, and select current account status + +- [ ] **Step 4: Run test to verify it passes** + +Reload the extension and verify: + +- the new provider is visible +- the section appears only when selected +- account rows render correctly from stored state + +- [ ] **Step 5: Commit** + +```bash +git add sidepanel/sidepanel.html sidepanel/sidepanel.css sidepanel/sidepanel.js +git commit -m "feat: add hotmail account pool panel" +``` + +### Task 3: Implement Microsoft OAuth Authorization + +**Files:** +- Modify: `background.js` +- Modify: `manifest.json` + +- [ ] **Step 1: Write the failing test** + +Define the expected authorization flow in a focused helper-oriented checklist: + +- PKCE code verifier/challenge can be created +- auth URL includes client ID, redirect URI, state, scope, and challenge +- callback code is parsed and validated +- token response updates the account record + +- [ ] **Step 2: Run test to verify it fails** + +Trigger the new `Authorize` action from the side panel. + +Expected: The action is not implemented yet and fails. + +- [ ] **Step 3: Write minimal implementation** + +Add background handlers and helpers for: + +- PKCE generation +- OAuth URL creation +- `chrome.identity.getRedirectURL()` +- `chrome.identity.launchWebAuthFlow` +- code exchange via Microsoft token endpoint +- account token persistence and error reporting + +- [ ] **Step 4: Run test to verify it passes** + +Manually authorize a Hotmail account and confirm: + +- the login flow opens +- tokens are saved to the target account +- account status becomes `authorized` + +- [ ] **Step 5: Commit** + +```bash +git add background.js manifest.json +git commit -m "feat: add microsoft oauth authorization" +``` + +### Task 4: Implement Token Refresh and Graph Mail Polling + +**Files:** +- Modify: `background.js` + +- [ ] **Step 1: Write the failing test** + +Define the expected helper behavior: + +- expired access token refreshes with `refresh_token` +- Graph mail fetch returns recent inbox messages +- filtering returns the newest matching verification code after the requested timestamp + +- [ ] **Step 2: Run test to verify it fails** + +Use the side panel `Test Mail Access` action before implementing the Graph path. + +Expected: The action fails because Graph mail polling does not exist yet. + +- [ ] **Step 3: Write minimal implementation** + +Add helpers in `background.js` for: + +- token freshness check +- refresh-token grant request +- Graph inbox fetch +- mail filtering +- verification code extraction + +- [ ] **Step 4: Run test to verify it passes** + +Authorize a Hotmail account and verify the test action can fetch mailbox data and surface a success or meaningful “no matching mail” response. + +- [ ] **Step 5: Commit** + +```bash +git add background.js +git commit -m "feat: add graph mail polling" +``` + +### Task 5: Wire Hotmail Provider into Step 3, Step 4, Step 7, and Auto Run + +**Files:** +- Modify: `background.js` +- Modify: `sidepanel/sidepanel.js` +- Modify: `README.md` + +- [ ] **Step 1: Write the failing test** + +Define the expected run behavior: + +- Auto mode chooses a fresh authorized Hotmail account for each new run +- Step 3 uses the selected account email and password +- Step 4 and Step 7 read verification mail through Graph instead of mailbox tabs + +- [ ] **Step 2: Run test to verify it fails** + +Select `hotmail-api` and run a manual or auto flow. + +Expected: The flow cannot yet allocate an account or fetch verification codes from Graph. + +- [ ] **Step 3: Write minimal implementation** + +Update: + +- provider branching in `getMailConfig()` or a replacement provider resolver +- account allocation at fresh run start +- Step 3 account-backed credentials +- Step 4 and Step 7 Graph polling path +- auto-run preconditions for Hotmail accounts + +- [ ] **Step 4: Run test to verify it passes** + +Run: + +1. manual Step 3 + Step 4 on a selected account +2. manual Step 6 + Step 7 on the same account +3. one full Auto run with `hotmail-api` + +Expected: no mailbox tab is opened for Hotmail, and verification codes come from Graph. + +- [ ] **Step 5: Commit** + +```bash +git add background.js sidepanel/sidepanel.js README.md +git commit -m "feat: integrate hotmail api provider into automation flow" +``` + +### Task 6: Regression Verification and Cleanup + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Write the failing test** + +List the required regression checks: + +- QQ provider still opens QQ mail tab +- 163 provider still opens 163 mail tab +- Inbucket provider still opens mailbox page +- Hotmail provider uses API path only + +- [ ] **Step 2: Run test to verify it fails** + +Manually inspect pre-change behavior expectations against the new code before cleanup. + +Expected: Any missing provider branch or broken selector is identified. + +- [ ] **Step 3: Write minimal implementation** + +Clean up labels, update README usage instructions, and ensure all branches show accurate UI copy. + +- [ ] **Step 4: Run test to verify it passes** + +Reload the extension and perform: + +- provider switch smoke test +- account add/delete smoke test +- one OAuth authorization smoke test +- one mail access smoke test + +- [ ] **Step 5: Commit** + +```bash +git add README.md +git commit -m "docs: document hotmail oauth mail provider" +``` diff --git a/docs/superpowers/specs/2026-04-10-hotmail-oauth-design.md b/docs/superpowers/specs/2026-04-10-hotmail-oauth-design.md new file mode 100644 index 0000000..67c0efa --- /dev/null +++ b/docs/superpowers/specs/2026-04-10-hotmail-oauth-design.md @@ -0,0 +1,197 @@ +# Hotmail OAuth + Graph Mail Design + +## Goal + +Replace the existing DuckDuckGo plus webmail polling flow with a Hotmail account pool that: + +- authorizes each Hotmail account inside the extension via Microsoft OAuth +- stores per-account tokens locally +- selects a fresh account for each automated run +- fetches verification emails through Microsoft Graph instead of mailbox page DOM polling + +The existing 1~9 step flow should remain intact wherever possible. The new work should be isolated to account selection, authorization, and email retrieval. + +## Existing Constraints + +- The project is a Manifest V3 Chrome extension with no build step. +- Runtime orchestration lives in `background.js`. +- The side panel is plain HTML/CSS/JS. +- Step 4 and Step 7 currently depend on provider-specific content scripts for QQ, 163, and Inbucket mailbox polling. +- Auto mode already supports retries, pauses, and restoring session state. + +## Design Summary + +The new Hotmail path introduces three focused subsystems: + +1. `hotmail-account-pool` + Maintains a reusable list of Hotmail accounts and their OAuth credentials. +2. `microsoft-oauth` + Handles Microsoft authorization code flow with PKCE through `chrome.identity.launchWebAuthFlow`. +3. `hotmail-graph-mail` + Reads inbox messages from Microsoft Graph and extracts verification codes for Step 4 and Step 7. + +The main flow continues to use the existing step state machine in `background.js`. + +## Architecture + +### 1. Account Pool + +Persist a new `hotmailAccounts` array in `chrome.storage.local`. + +Each account record stores: + +- `id` +- `email` +- `password` +- `clientId` +- `accessToken` +- `refreshToken` +- `expiresAt` +- `status` +- `lastUsedAt` +- `lastAuthAt` +- `lastError` + +The current run stores `currentHotmailAccountId` in `chrome.storage.session`. + +When `mailProvider = hotmail-api`, Auto mode must allocate one account at the start of a fresh run, write its email into the existing `email` runtime field, and reuse the same account through Step 3, Step 4, Step 6, and Step 7. + +### 2. OAuth Flow + +Each account is authorized separately from the side panel. + +Flow: + +1. User clicks `Authorize` on an account row. +2. Background generates PKCE verifier/challenge and a random `state`. +3. Background launches Microsoft sign-in via `chrome.identity.launchWebAuthFlow`. +4. Microsoft redirects back to the extension redirect URL. +5. Background validates `state`, exchanges `code` for tokens, and updates the account record. + +The extension requests delegated scopes only: + +- `openid` +- `profile` +- `offline_access` +- `https://graph.microsoft.com/Mail.Read` +- `https://graph.microsoft.com/User.Read` + +The design assumes one shared `clientId` across accounts is valid, while `refreshToken` remains per account. + +### 3. Graph Mail Retrieval + +Hotmail mail retrieval runs inside background logic and does not require a mail tab or content script. + +The provider performs: + +1. Resolve the current Hotmail account from session state. +2. Refresh the token if missing or near expiry. +3. Call Microsoft Graph to fetch recent inbox messages. +4. Filter by sender, subject, and time window. +5. Extract a 6-digit verification code from message metadata or preview/body. +6. Return the code to the existing Step 4 or Step 7 submission flow. + +The first iteration should prefer stable fields such as: + +- `from.emailAddress.address` +- `subject` +- `receivedDateTime` +- `bodyPreview` + +Full HTML body parsing is explicitly deferred unless needed. + +## Integration with Existing Steps + +### Step 3 + +Step 3 keeps filling the OpenAI page in the same way, but when `mailProvider = hotmail-api`, the email comes from the selected account pool entry instead of the manual email box or Duck address. + +### Step 4 and Step 7 + +The existing retry and resend behavior stays in place, but the provider path changes: + +- old providers: `qq`, `163`, `inbucket` +- new provider: `hotmail-api` + +The orchestration layer should branch before opening any mailbox tab. For `hotmail-api`, it calls a background helper instead of `sendToMailContentScriptResilient`. + +### Auto Run + +Auto run changes: + +- remove the Duck auto-fetch dependency when `hotmail-api` is selected +- allocate a fresh Hotmail account at the start of a new run +- fail early if no authorized account is available +- preserve the existing retry and skip-failure semantics + +## Side Panel Changes + +The current single email entry remains for backward compatibility, but a new account-pool section is added when `hotmail-api` is selected. + +New UI capabilities: + +- list Hotmail accounts +- add an account +- delete an account +- authorize an account +- test mail access for an account +- show status and last error + +The existing provider selector gains a `hotmail-api` option. + +## Error Handling + +The new path must surface actionable errors: + +- no Hotmail account available +- missing `clientId` +- OAuth denied or cancelled +- token exchange failed +- refresh token invalid +- Graph mail read failed +- no matching verification mail found in the time window + +Account-level failures should update the account record `status` and `lastError` without corrupting unrelated accounts. + +## Security and Storage Tradeoffs + +- Tokens and account passwords are stored in `chrome.storage.local` for operator convenience. +- This is acceptable for the current operator-managed extension model, but it increases the trust requirement of the local browser profile. +- No secret should be hard-coded in the repository. + +## Testing Strategy + +Because the project has no automated test harness today, implementation should carve out pure helper functions where possible and validate them with focused runtime checks. + +The minimum verification surface: + +- account selection logic +- PKCE helper generation +- OAuth callback parsing and state validation +- token refresh request/response handling +- Graph message filtering and code extraction +- Step 4 and Step 7 provider branch behavior +- Auto run allocation of a fresh account per run + +## Deferred Work + +The first version intentionally excludes: + +- bulk import/export UX +- bulk authorize all accounts +- advanced HTML message parsing +- mailbox delete/move/archive behavior +- background local service or server proxy +- removal of existing QQ/163/Inbucket support + +## Implementation Boundary + +Modify only the minimum set of files needed to add the new provider while keeping current providers operational: + +- `manifest.json` +- `background.js` +- `sidepanel/sidepanel.html` +- `sidepanel/sidepanel.css` +- `sidepanel/sidepanel.js` + +If helper extraction becomes necessary, prefer adding small new files under `content/` or the repo root only if they clearly reduce complexity in `background.js`. diff --git a/hotmail-utils.js b/hotmail-utils.js new file mode 100644 index 0000000..1721c83 --- /dev/null +++ b/hotmail-utils.js @@ -0,0 +1,392 @@ +(function hotmailUtilsModule(root, factory) { + if (typeof module !== 'undefined' && module.exports) { + module.exports = factory(); + return; + } + + root.HotmailUtils = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createHotmailUtils() { + const HOTMAIL_MAIL_API_URL = 'https://apple.882263.xyz/api/mail-new'; + + function normalizeText(value) { + return String(value || '') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); + } + + function normalizeTimestamp(value) { + if (!value) return 0; + if (typeof value === 'number' && Number.isFinite(value)) { + return value > 0 ? value : 0; + } + + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : 0; + } + + function extractVerificationCode(text) { + const source = String(text || ''); + const matchCn = source.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/i); + if (matchCn) return matchCn[1]; + + const matchEn = source.match(/code(?:\s+is|[\s:])+(\d{6})/i); + if (matchEn) return matchEn[1]; + + const matchStandalone = source.match(/\b(\d{6})\b/); + return matchStandalone ? matchStandalone[1] : null; + } + + function extractVerificationCodeFromMessage(message = {}) { + const sender = firstNonEmptyString([ + message?.from?.emailAddress?.address, + message?.sender, + message?.from, + ]); + const subject = firstNonEmptyString([message?.subject]); + const preview = firstNonEmptyString([message?.bodyPreview, message?.preview, message?.text]); + return extractVerificationCode([subject, preview, sender].filter(Boolean).join(' ')); + } + + function getLatestHotmailMessage(messages) { + return (Array.isArray(messages) ? messages : []) + .slice() + .sort((left, right) => { + const leftTime = normalizeTimestamp(left?.receivedDateTime); + const rightTime = normalizeTimestamp(right?.receivedDateTime); + return rightTime - leftTime; + })[0] || null; + } + + function getHotmailListToggleLabel(expanded, count = 0) { + const normalizedCount = Number.isFinite(Number(count)) ? Math.max(0, Number(count)) : 0; + const suffix = normalizedCount > 0 ? `(${normalizedCount})` : ''; + return `${expanded ? '收起列表' : '展开列表'}${suffix}`; + } + + function filterHotmailAccountsByUsage(accounts, mode = 'all') { + const list = Array.isArray(accounts) ? accounts.slice() : []; + if (mode === 'used') { + return list.filter((account) => Boolean(account?.used)); + } + return list; + } + + function getHotmailBulkActionLabel(mode = 'all', count = 0) { + const normalizedCount = Number.isFinite(Number(count)) ? Math.max(0, Number(count)) : 0; + const prefix = mode === 'used' ? '清空已用' : '全部删除'; + const suffix = normalizedCount > 0 ? `(${normalizedCount})` : ''; + return `${prefix}${suffix}`; + } + + function isAuthorizedHotmailAccount(account) { + return Boolean(account) + && account.status === 'authorized' + && !account.used + && Boolean(account.refreshToken); + } + + function shouldClearHotmailCurrentSelection(account) { + return Boolean(account) && account.used === true; + } + + function upsertHotmailAccountInList(accounts, nextAccount) { + const list = Array.isArray(accounts) ? accounts.slice() : []; + if (!nextAccount?.id) return list; + + const existingIndex = list.findIndex((account) => account?.id === nextAccount.id); + if (existingIndex === -1) { + list.push(nextAccount); + return list; + } + + list[existingIndex] = nextAccount; + return list; + } + + function pickHotmailAccountForRun(accounts, options = {}) { + const candidates = Array.isArray(accounts) ? accounts.filter(isAuthorizedHotmailAccount) : []; + if (!candidates.length) return null; + + const excludeIds = new Set((options.excludeIds || []).filter(Boolean)); + const filtered = candidates.filter((account) => !excludeIds.has(account.id)); + const pool = filtered.length ? filtered : candidates; + + return pool + .slice() + .sort((left, right) => { + const leftUsedAt = normalizeTimestamp(left.lastUsedAt); + const rightUsedAt = normalizeTimestamp(right.lastUsedAt); + if (leftUsedAt !== rightUsedAt) { + return leftUsedAt - rightUsedAt; + } + + return String(left.email || '').localeCompare(String(right.email || '')); + })[0] || null; + } + + function messageMatchesFilters(message, filters = {}) { + const senderFilters = (filters.senderFilters || []).map(normalizeText).filter(Boolean); + const subjectFilters = (filters.subjectFilters || []).map(normalizeText).filter(Boolean); + const afterTimestamp = normalizeTimestamp(filters.afterTimestamp); + const receivedAt = normalizeTimestamp(message?.receivedDateTime); + if (afterTimestamp && receivedAt && receivedAt < afterTimestamp) { + return null; + } + + const sender = normalizeText(message?.from?.emailAddress?.address); + const subject = normalizeText(message?.subject); + const preview = String(message?.bodyPreview || ''); + const combinedText = [subject, sender, preview].filter(Boolean).join(' '); + const code = extractVerificationCode(combinedText); + const excludedCodes = new Set((filters.excludeCodes || []).filter(Boolean)); + if (code && excludedCodes.has(code)) { + return null; + } + + const senderMatch = senderFilters.length === 0 + ? true + : senderFilters.some((item) => sender.includes(item) || normalizeText(preview).includes(item)); + const subjectMatch = subjectFilters.length === 0 + ? true + : subjectFilters.some((item) => subject.includes(item) || normalizeText(preview).includes(item)); + + if (!senderMatch && !subjectMatch) { + return null; + } + + if (!code) { + return null; + } + + return { + code, + message, + receivedAt, + }; + } + + function pickVerificationMessage(messages, filters = {}) { + const matches = (Array.isArray(messages) ? messages : []) + .map((message) => messageMatchesFilters(message, filters)) + .filter(Boolean) + .sort((left, right) => right.receivedAt - left.receivedAt); + + return matches[0] || null; + } + + function pickVerificationMessageWithFallback(messages, filters = {}) { + const strictMatch = pickVerificationMessage(messages, filters); + if (strictMatch) { + return { + match: strictMatch, + usedRelaxedFilters: false, + usedTimeFallback: false, + }; + } + + const relaxedMatch = pickVerificationMessage(messages, { + afterTimestamp: filters.afterTimestamp, + excludeCodes: filters.excludeCodes, + senderFilters: [], + subjectFilters: [], + }); + + return { + match: relaxedMatch || null, + usedRelaxedFilters: Boolean(relaxedMatch), + usedTimeFallback: false, + }; + + /* c8 ignore start */ + } + + function pickVerificationMessageWithTimeFallback(messages, filters = {}) { + const strictOrRelaxedResult = pickVerificationMessageWithFallback(messages, filters); + if (strictOrRelaxedResult.match) { + return strictOrRelaxedResult; + } + + const timeFallbackMatch = pickVerificationMessage(messages, { + afterTimestamp: 0, + excludeCodes: filters.excludeCodes, + senderFilters: [], + subjectFilters: [], + }); + + return { + match: timeFallbackMatch || null, + usedRelaxedFilters: Boolean(timeFallbackMatch), + usedTimeFallback: Boolean(timeFallbackMatch), + }; + /* c8 ignore stop */ + } + + function firstNonEmptyString(values) { + for (const value of values) { + if (value === undefined || value === null) continue; + const normalized = String(value).trim(); + if (normalized) return normalized; + } + return ''; + } + + function normalizeMailAddress(rawValue) { + if (!rawValue) return ''; + if (typeof rawValue === 'string') { + return rawValue.trim(); + } + if (typeof rawValue === 'object') { + return firstNonEmptyString([ + rawValue.emailAddress?.address, + rawValue.address, + rawValue.email, + rawValue.sender, + rawValue.from, + ]); + } + return ''; + } + + function stripHtmlTags(text) { + return String(text || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); + } + + function normalizeHotmailMailApiMessage(message = {}) { + return { + id: firstNonEmptyString([message.id, message.message_id, message.messageId, message.internetMessageId]), + subject: firstNonEmptyString([message.subject, message.title]), + from: { + emailAddress: { + address: normalizeMailAddress( + message.from_email + || message.sender_email + || message.from + || message.sender + || message.emailAddress + ), + }, + }, + bodyPreview: firstNonEmptyString([ + message.bodyPreview, + message.preview, + message.snippet, + message.text, + message.body, + stripHtmlTags(message.html || message.content || ''), + ]), + receivedDateTime: firstNonEmptyString([ + message.receivedDateTime, + message.received_at, + message.receivedAt, + message.date, + message.created_at, + message.time, + ]), + }; + } + + function normalizeHotmailMailApiMessages(messages) { + const list = Array.isArray(messages) + ? messages + : (messages ? [messages] : []); + return list.map((message) => normalizeHotmailMailApiMessage(message)); + } + + function buildHotmailMailApiLatestUrl(options) { + const url = new URL(HOTMAIL_MAIL_API_URL); + url.searchParams.set('refresh_token', String(options?.refreshToken || '')); + url.searchParams.set('client_id', String(options?.clientId || '')); + url.searchParams.set('email', String(options?.email || '')); + url.searchParams.set('mailbox', String(options?.mailbox || 'INBOX')); + url.searchParams.set('response_type', String(options?.responseType || 'json')); + return url.toString(); + } + + function getHotmailVerificationPollConfig(step) { + if (step === 4 || step === 7) { + return { + initialDelayMs: 5000, + maxAttempts: 12, + intervalMs: 5000, + requestFreshCodeFirst: false, + ignorePersistedLastCode: true, + }; + } + + return { + initialDelayMs: 5000, + maxAttempts: 8, + intervalMs: 4000, + requestFreshCodeFirst: false, + ignorePersistedLastCode: true, + }; + } + + function getHotmailVerificationRequestTimestamp(step, state = {}, options = {}) { + const bufferMs = Number(options.bufferMs) || 15_000; + const signupRequestedAt = normalizeTimestamp(state.signupVerificationRequestedAt); + const loginRequestedAt = normalizeTimestamp(state.loginVerificationRequestedAt); + const lastEmailTimestamp = normalizeTimestamp(state.lastEmailTimestamp); + const flowStartTime = normalizeTimestamp(state.flowStartTime); + + if (step === 4 && signupRequestedAt) { + return Math.max(0, signupRequestedAt - bufferMs); + } + + if (step === 7 && loginRequestedAt) { + return Math.max(0, loginRequestedAt - bufferMs); + } + + return step === 7 + ? (lastEmailTimestamp || flowStartTime || 0) + : (flowStartTime || 0); + } + + function getHotmailMailApiRequestConfig() { + return { + timeoutMs: 15000, + }; + } + + function parseHotmailImportText(rawText) { + const lines = String(rawText || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + return lines + .filter((line, index) => !(index === 0 && /^账号----密码----ID----Token$/i.test(line))) + .map((line) => line.split('----').map((part) => part.trim())) + .filter((parts) => parts.length >= 4 && parts[0] && parts[2]) + .map(([email, password, clientId, refreshToken]) => ({ + email, + password, + clientId, + refreshToken, + })); + } + + return { + buildHotmailMailApiLatestUrl, + extractVerificationCodeFromMessage, + filterHotmailAccountsByUsage, + extractVerificationCode, + getLatestHotmailMessage, + getHotmailBulkActionLabel, + getHotmailListToggleLabel, + getHotmailMailApiRequestConfig, + getHotmailVerificationPollConfig, + getHotmailVerificationRequestTimestamp, + isAuthorizedHotmailAccount, + normalizeHotmailMailApiMessages, + normalizeTimestamp, + parseHotmailImportText, + pickHotmailAccountForRun, + pickVerificationMessage, + pickVerificationMessageWithFallback, + pickVerificationMessageWithTimeFallback, + shouldClearHotmailCurrentSelection, + upsertHotmailAccountInList, + }; +}); diff --git a/manifest.json b/manifest.json index 7d486ff..352103f 100644 --- a/manifest.json +++ b/manifest.json @@ -28,7 +28,7 @@ "https://auth.openai.com/*", "https://accounts.openai.com/*" ], - "js": ["content/utils.js", "content/signup-page.js"], + "js": ["content/activation-utils.js", "content/utils.js", "content/signup-page.js"], "run_at": "document_idle" }, { @@ -36,7 +36,7 @@ "https://mail.qq.com/*", "https://wx.mail.qq.com/*" ], - "js": ["content/utils.js", "content/qq-mail.js"], + "js": ["content/activation-utils.js", "content/utils.js", "content/qq-mail.js"], "all_frames": true, "run_at": "document_idle" }, @@ -44,7 +44,7 @@ "matches": [ "https://mail.163.com/*" ], - "js": ["content/utils.js", "content/mail-163.js"], + "js": ["content/activation-utils.js", "content/utils.js", "content/mail-163.js"], "all_frames": true, "run_at": "document_idle" }, @@ -52,7 +52,7 @@ "matches": [ "https://duckduckgo.com/email/settings/autofill*" ], - "js": ["content/utils.js", "content/duck-mail.js"], + "js": ["content/activation-utils.js", "content/utils.js", "content/duck-mail.js"], "run_at": "document_idle" } ], diff --git a/package.json b/package.json new file mode 100644 index 0000000..b5b6be8 --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "name": "codex-oauth-automation-extension", + "private": true, + "scripts": { + "test": "node --test tests/*.test.js" + } +} diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index 3fb033f..21e6cbc 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -238,6 +238,33 @@ header { box-shadow: var(--shadow-sm); } +.section-mini-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.section-mini-copy { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.section-mini-actions { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.section-hint { + color: var(--text-muted); + font-size: 12px; +} + .data-row { display: flex; align-items: center; @@ -309,6 +336,23 @@ header { .data-input::placeholder { color: var(--text-muted); } .data-input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); } +.data-textarea { + width: 100%; + min-height: 88px; + resize: vertical; + padding: 8px 10px; + background: var(--bg-base); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + outline: none; + transition: border-color var(--transition), box-shadow var(--transition); +} +.data-textarea::placeholder { color: var(--text-muted); } +.data-textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); } + .data-input-with-icon { padding-right: 38px; } @@ -340,6 +384,189 @@ header { flex-shrink: 0; } +.hotmail-card { + margin-top: 10px; +} + +.hotmail-actions-row .data-label { + visibility: hidden; +} + +.hotmail-import-row { + align-items: flex-start; +} + +.hotmail-import-box { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; +} + +.hotmail-list-shell { + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-base) 82%, transparent); + overflow-y: auto; + overflow-x: hidden; + padding: 2px; + transition: max-height var(--transition), border-color var(--transition), box-shadow var(--transition); +} + +.hotmail-list-shell.is-collapsed { + max-height: 236px; +} + +.hotmail-list-shell.is-expanded { + max-height: 440px; +} + +.hotmail-list-shell::-webkit-scrollbar { + width: 8px; +} + +.hotmail-list-shell::-webkit-scrollbar-track { + background: transparent; +} + +.hotmail-list-shell::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 999px; +} + +.hotmail-list-shell::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +.hotmail-accounts-list { + display: flex; + flex-direction: column; + gap: 8px; + padding-right: 4px; +} + +.hotmail-account-item { + border: 1px solid var(--border); + background: var(--bg-base); + border-radius: var(--radius-sm); + padding: 10px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.hotmail-account-item.is-current { + border-color: var(--blue); + box-shadow: 0 0 0 1px var(--blue-glow); +} + +.hotmail-account-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.hotmail-account-title-row { + min-width: 0; + display: flex; + align-items: center; + gap: 6px; +} + +.hotmail-account-email { + font-weight: 600; + color: var(--text-primary); + word-break: break-all; +} + +.hotmail-copy-btn { + width: 24px; + height: 24px; + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 999px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: background var(--transition), color var(--transition), transform var(--transition); +} + +.hotmail-copy-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.hotmail-copy-btn:active { + transform: scale(0.96); +} + +.hotmail-status-chip { + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.hotmail-status-chip.status-authorized { + background: var(--green-soft); + color: var(--green); +} + +.hotmail-status-chip.status-used { + background: var(--orange-soft); + color: var(--orange); +} + +.hotmail-status-chip.status-disabled { + background: var(--bg-surface); + color: var(--text-secondary); +} + +.hotmail-status-chip.status-pending { + background: var(--orange-soft); + color: var(--orange); +} + +.hotmail-status-chip.status-error { + background: var(--red-soft); + color: var(--red); +} + +.hotmail-account-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px 10px; + color: var(--text-secondary); + font-size: 12px; +} + +.hotmail-account-error { + font-size: 12px; + color: var(--red); + word-break: break-word; +} + +.hotmail-account-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.hotmail-empty { + color: var(--text-muted); + font-size: 12px; + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + padding: 10px; + text-align: center; +} + .data-select { flex: 1; padding: 7px 10px; diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index 85aab75..3e9cc51 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -55,6 +55,7 @@
邮箱服务 +
+
+ Client ID + +
+
+ 邮箱密码 + +
+
+ Refresh + +
+
+ + +
+
+ 批量导入 +
+ + +
+
+ +
就绪 @@ -190,6 +234,7 @@
+ diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 6319445..36c748d 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -30,6 +30,19 @@ const btnClearLog = document.getElementById('btn-clear-log'); const inputVpsUrl = document.getElementById('input-vps-url'); const inputVpsPassword = document.getElementById('input-vps-password'); const selectMailProvider = document.getElementById('select-mail-provider'); +const hotmailSection = document.getElementById('hotmail-section'); +const inputHotmailEmail = document.getElementById('input-hotmail-email'); +const inputHotmailClientId = document.getElementById('input-hotmail-client-id'); +const inputHotmailPassword = document.getElementById('input-hotmail-password'); +const inputHotmailRefreshToken = document.getElementById('input-hotmail-refresh-token'); +const inputHotmailImport = document.getElementById('input-hotmail-import'); +const btnAddHotmailAccount = document.getElementById('btn-add-hotmail-account'); +const btnImportHotmailAccounts = document.getElementById('btn-import-hotmail-accounts'); +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 hotmailListShell = document.getElementById('hotmail-list-shell'); +const hotmailAccountsList = document.getElementById('hotmail-accounts-list'); const rowInbucketHost = document.getElementById('row-inbucket-host'); const inputInbucketHost = document.getElementById('input-inbucket-host'); const rowInbucketMailbox = document.getElementById('row-inbucket-mailbox'); @@ -43,6 +56,7 @@ const btnAutoStartClose = document.getElementById('btn-auto-start-close'); const btnAutoStartCancel = document.getElementById('btn-auto-start-cancel'); const btnAutoStartRestart = document.getElementById('btn-auto-start-restart'); const btnAutoStartContinue = document.getElementById('btn-auto-start-continue'); +const autoHintText = document.querySelector('.auto-hint'); const STEP_DEFAULT_STATUSES = { 1: 'pending', 2: 'pending', @@ -69,9 +83,19 @@ let settingsSaveInFlight = false; let settingsAutoSaveTimer = null; let modalChoiceResolver = null; let currentModalActions = []; +let hotmailActionInFlight = false; +let hotmailListExpanded = false; const EYE_OPEN_ICON = ''; const EYE_CLOSED_ICON = ''; +const COPY_ICON = ''; +const parseHotmailImportText = window.HotmailUtils?.parseHotmailImportText; +const shouldClearHotmailCurrentSelection = window.HotmailUtils?.shouldClearHotmailCurrentSelection; +const upsertHotmailAccountInList = window.HotmailUtils?.upsertHotmailAccountInList; +const filterHotmailAccountsByUsage = window.HotmailUtils?.filterHotmailAccountsByUsage; +const getHotmailBulkActionLabel = window.HotmailUtils?.getHotmailBulkActionLabel; +const getHotmailListToggleLabel = window.HotmailUtils?.getHotmailListToggleLabel; +const HOTMAIL_LIST_EXPANDED_STORAGE_KEY = 'multipage-hotmail-list-expanded'; // ============================================================ // Toast Notifications @@ -478,10 +502,241 @@ function syncPasswordField(state) { inputPassword.value = state.customPassword || state.password || ''; } +function getHotmailAccounts(state = latestState) { + return Array.isArray(state?.hotmailAccounts) ? state.hotmailAccounts : []; +} + +function getCurrentHotmailAccount(state = latestState) { + const currentId = state?.currentHotmailAccountId; + return getHotmailAccounts(state).find((account) => account.id === currentId) || null; +} + +function getHotmailAccountsByUsage(mode = 'all', state = latestState) { + const accounts = getHotmailAccounts(state); + if (typeof filterHotmailAccountsByUsage === 'function') { + return filterHotmailAccountsByUsage(accounts, mode); + } + if (mode === 'used') { + return accounts.filter((account) => Boolean(account?.used)); + } + return accounts.slice(); +} + +function getHotmailBulkActionText(mode, count) { + if (typeof getHotmailBulkActionLabel === 'function') { + return getHotmailBulkActionLabel(mode, count); + } + const normalizedCount = Number.isFinite(Number(count)) ? Math.max(0, Number(count)) : 0; + const prefix = mode === 'used' ? '清空已用' : '全部删除'; + const suffix = normalizedCount > 0 ? `(${normalizedCount})` : ''; + return `${prefix}${suffix}`; +} + +function getHotmailListToggleText(expanded, count) { + if (typeof getHotmailListToggleLabel === 'function') { + return getHotmailListToggleLabel(expanded, count); + } + const normalizedCount = Number.isFinite(Number(count)) ? Math.max(0, Number(count)) : 0; + const suffix = normalizedCount > 0 ? `(${normalizedCount})` : ''; + return `${expanded ? '收起列表' : '展开列表'}${suffix}`; +} + +function updateHotmailListViewport() { + const count = getHotmailAccounts().length; + const usedCount = getHotmailAccountsByUsage('used').length; + if (btnClearUsedHotmailAccounts) { + btnClearUsedHotmailAccounts.textContent = getHotmailBulkActionText('used', usedCount); + btnClearUsedHotmailAccounts.disabled = usedCount === 0; + } + if (btnDeleteAllHotmailAccounts) { + btnDeleteAllHotmailAccounts.textContent = getHotmailBulkActionText('all', count); + btnDeleteAllHotmailAccounts.disabled = count === 0; + } + if (btnToggleHotmailList) { + btnToggleHotmailList.textContent = getHotmailListToggleText(hotmailListExpanded, count); + btnToggleHotmailList.setAttribute('aria-expanded', String(hotmailListExpanded)); + btnToggleHotmailList.disabled = count === 0; + } + if (hotmailListShell) { + hotmailListShell.classList.toggle('is-expanded', hotmailListExpanded); + hotmailListShell.classList.toggle('is-collapsed', !hotmailListExpanded); + } +} + +function setHotmailListExpanded(expanded, options = {}) { + const { persist = true } = options; + hotmailListExpanded = Boolean(expanded); + updateHotmailListViewport(); + if (persist) { + localStorage.setItem(HOTMAIL_LIST_EXPANDED_STORAGE_KEY, hotmailListExpanded ? '1' : '0'); + } +} + +function initHotmailListExpandedState() { + const saved = localStorage.getItem(HOTMAIL_LIST_EXPANDED_STORAGE_KEY); + setHotmailListExpanded(saved === '1', { persist: false }); +} + +function shouldClearCurrentHotmailSelectionLocally(account) { + if (typeof shouldClearHotmailCurrentSelection === 'function') { + return shouldClearHotmailCurrentSelection(account); + } + return Boolean(account) && account.used === true; +} + +function upsertHotmailAccountListLocally(accounts, nextAccount) { + if (typeof upsertHotmailAccountInList === 'function') { + return upsertHotmailAccountInList(accounts, nextAccount); + } + + const list = Array.isArray(accounts) ? accounts.slice() : []; + if (!nextAccount?.id) return list; + + const existingIndex = list.findIndex((account) => account?.id === nextAccount.id); + if (existingIndex === -1) { + list.push(nextAccount); + return list; + } + + list[existingIndex] = nextAccount; + return list; +} + +function refreshHotmailSelectionUI() { + renderHotmailAccounts(); + if (selectMailProvider.value === 'hotmail-api') { + const currentAccount = getCurrentHotmailAccount(); + inputEmail.value = currentAccount?.email || latestState?.email || ''; + } +} + +function applyHotmailAccountMutation(account, options = {}) { + if (!account?.id) return; + const { preserveCurrentSelection = false } = options; + + const nextState = { + hotmailAccounts: upsertHotmailAccountListLocally(getHotmailAccounts(), account), + }; + + if (!preserveCurrentSelection + && latestState?.currentHotmailAccountId === account.id + && shouldClearCurrentHotmailSelectionLocally(account)) { + nextState.currentHotmailAccountId = null; + if (selectMailProvider.value === 'hotmail-api') { + nextState.email = null; + } + } + + syncLatestState(nextState); + refreshHotmailSelectionUI(); +} + +function formatDateTime(timestamp) { + const value = Number(timestamp); + if (!Number.isFinite(value) || value <= 0) { + return '未使用'; + } + return new Date(value).toLocaleString('zh-CN', { hour12: false }); +} + +function getHotmailAvailabilityLabel(account) { + if (account.used) return '已用'; + return '可分配'; +} + +function getHotmailStatusLabel(account) { + if (account.used) return '已用'; + + switch (account.status) { + case 'authorized': + return '可用'; + case 'error': + return '异常'; + default: + return '待校验'; + } +} + +function getHotmailStatusClass(account) { + if (account.used) return 'status-used'; + return `status-${account.status || 'pending'}`; +} + +function clearHotmailForm() { + inputHotmailEmail.value = ''; + inputHotmailClientId.value = ''; + inputHotmailPassword.value = ''; + inputHotmailRefreshToken.value = ''; +} + +function renderHotmailAccounts() { + if (!hotmailAccountsList) return; + const accounts = getHotmailAccounts(); + const currentId = latestState?.currentHotmailAccountId || ''; + + if (!accounts.length) { + hotmailAccountsList.innerHTML = '
还没有 Hotmail 账号,先添加一条再校验。
'; + updateHotmailListViewport(); + return; + } + + hotmailAccountsList.innerHTML = accounts.map((account) => ` + + `).join(''); + updateHotmailListViewport(); +} + function updateMailProviderUI() { const useInbucket = selectMailProvider.value === 'inbucket'; + const useHotmail = selectMailProvider.value === 'hotmail-api'; rowInbucketHost.style.display = useInbucket ? '' : 'none'; rowInbucketMailbox.style.display = useInbucket ? '' : 'none'; + if (hotmailSection) { + hotmailSection.style.display = useHotmail ? '' : 'none'; + } + btnFetchEmail.hidden = useHotmail; + inputEmail.readOnly = useHotmail; + inputEmail.placeholder = useHotmail ? '由 Hotmail 账号池自动分配' : '粘贴 DuckDuckGo 邮箱'; + if (autoHintText) { + autoHintText.textContent = useHotmail + ? '请先校验并选择一个 Hotmail 账号' + : '先自动获取 Duck 邮箱,或手动粘贴邮箱后再继续'; + } + if (useHotmail) { + const currentAccount = getCurrentHotmailAccount(); + inputEmail.value = currentAccount?.email || latestState?.email || ''; + } + renderHotmailAccounts(); } // ============================================================ @@ -680,6 +935,70 @@ async function fetchDuckEmail(options = {}) { } } +async function copyTextToClipboard(text) { + const value = String(text || '').trim(); + if (!value) { + throw new Error('没有可复制的内容。'); + } + if (!navigator.clipboard?.writeText) { + throw new Error('当前环境不支持剪贴板复制。'); + } + await navigator.clipboard.writeText(value); +} + +async function deleteHotmailAccountsByMode(mode) { + const isUsedMode = mode === 'used'; + const targetAccounts = getHotmailAccountsByUsage(isUsedMode ? 'used' : 'all'); + if (!targetAccounts.length) { + showToast(isUsedMode ? '没有已用账号可清空。' : '没有可删除的 Hotmail 账号。', 'warn'); + return; + } + + const confirmed = await openConfirmModal({ + title: isUsedMode ? '清空已用账号' : '全部删除账号', + message: isUsedMode + ? `确认删除当前 ${targetAccounts.length} 个已用 Hotmail 账号吗?` + : `确认删除全部 ${targetAccounts.length} 个 Hotmail 账号吗?`, + confirmLabel: isUsedMode ? '确认清空已用' : '确认全部删除', + confirmVariant: isUsedMode ? 'btn-outline' : 'btn-danger', + }); + if (!confirmed) { + return; + } + + const response = await chrome.runtime.sendMessage({ + type: 'DELETE_HOTMAIL_ACCOUNTS', + source: 'sidepanel', + payload: { mode: isUsedMode ? 'used' : 'all' }, + }); + + if (response?.error) { + throw new Error(response.error); + } + + const targetIds = new Set(targetAccounts.map((account) => account.id)); + const nextAccounts = isUsedMode + ? getHotmailAccounts().filter((account) => !targetIds.has(account.id)) + : []; + const nextState = { hotmailAccounts: nextAccounts }; + if (latestState?.currentHotmailAccountId && targetIds.has(latestState.currentHotmailAccountId)) { + nextState.currentHotmailAccountId = null; + if (selectMailProvider.value === 'hotmail-api') { + nextState.email = null; + } + } + syncLatestState(nextState); + refreshHotmailSelectionUI(); + + showToast( + isUsedMode + ? `已清空 ${response.deletedCount || 0} 个已用 Hotmail 账号` + : `已删除全部 ${response.deletedCount || 0} 个 Hotmail 账号`, + 'success', + 2200 + ); +} + function syncPasswordToggleLabel() { const isHidden = inputPassword.type === 'password'; btnTogglePassword.innerHTML = isHidden ? EYE_OPEN_ICON : EYE_CLOSED_ICON; @@ -754,18 +1073,25 @@ document.querySelectorAll('.step-btn').forEach(btn => { }); syncLatestState({ customPassword: inputPassword.value }); } - let email = inputEmail.value.trim(); - if (!email) { - try { - email = await fetchDuckEmail({ showFailureToast: false }); - } catch (err) { - showToast(`自动获取失败:${err.message},请手动粘贴邮箱后重试。`, 'warn'); - return; + if (selectMailProvider.value === 'hotmail-api') { + const response = await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } }); + if (response?.error) { + throw new Error(response.error); + } + } else { + let email = inputEmail.value.trim(); + if (!email) { + try { + email = await fetchDuckEmail({ showFailureToast: false }); + } catch (err) { + showToast(`自动获取失败:${err.message},请手动粘贴邮箱后重试。`, 'warn'); + return; + } + } + const response = await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step, email } }); + if (response?.error) { + throw new Error(response.error); } - } - const response = await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step, email } }); - if (response?.error) { - throw new Error(response.error); } } else { const response = await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } }); @@ -780,9 +1106,234 @@ document.querySelectorAll('.step-btn').forEach(btn => { }); btnFetchEmail.addEventListener('click', async () => { + if (selectMailProvider.value === 'hotmail-api') { + return; + } await fetchDuckEmail().catch(() => {}); }); +btnToggleHotmailList?.addEventListener('click', () => { + setHotmailListExpanded(!hotmailListExpanded); +}); + +btnClearUsedHotmailAccounts?.addEventListener('click', async () => { + if (hotmailActionInFlight) return; + hotmailActionInFlight = true; + btnClearUsedHotmailAccounts.disabled = true; + try { + await deleteHotmailAccountsByMode('used'); + } catch (err) { + showToast(err.message, 'error'); + } finally { + hotmailActionInFlight = false; + updateHotmailListViewport(); + } +}); + +btnDeleteAllHotmailAccounts?.addEventListener('click', async () => { + if (hotmailActionInFlight) return; + hotmailActionInFlight = true; + btnDeleteAllHotmailAccounts.disabled = true; + try { + await deleteHotmailAccountsByMode('all'); + } catch (err) { + showToast(err.message, 'error'); + } finally { + hotmailActionInFlight = false; + updateHotmailListViewport(); + } +}); + +btnAddHotmailAccount?.addEventListener('click', async () => { + if (hotmailActionInFlight) return; + + const email = inputHotmailEmail.value.trim(); + const clientId = inputHotmailClientId.value.trim(); + const refreshToken = inputHotmailRefreshToken.value.trim(); + if (!email) { + showToast('请先填写 Hotmail 邮箱。', 'warn'); + return; + } + if (!clientId) { + showToast('请先填写 Microsoft client ID。', 'warn'); + return; + } + if (!refreshToken) { + showToast('请先填写 refresh token。', 'warn'); + return; + } + + hotmailActionInFlight = true; + btnAddHotmailAccount.disabled = true; + + try { + const response = await chrome.runtime.sendMessage({ + type: 'UPSERT_HOTMAIL_ACCOUNT', + source: 'sidepanel', + payload: { + email, + clientId, + password: inputHotmailPassword.value, + refreshToken, + }, + }); + + if (response?.error) { + throw new Error(response.error); + } + + showToast(`已保存 Hotmail 账号 ${email}`, 'success', 1800); + clearHotmailForm(); + } catch (err) { + showToast(`保存 Hotmail 账号失败:${err.message}`, 'error'); + } finally { + hotmailActionInFlight = false; + btnAddHotmailAccount.disabled = false; + } +}); + +btnImportHotmailAccounts?.addEventListener('click', async () => { + if (hotmailActionInFlight) return; + if (typeof parseHotmailImportText !== 'function') { + showToast('导入解析器未加载,请刷新扩展后重试。', 'error'); + return; + } + + const rawText = inputHotmailImport.value.trim(); + if (!rawText) { + showToast('请先粘贴账号导入内容。', 'warn'); + return; + } + + const parsedAccounts = parseHotmailImportText(rawText); + if (!parsedAccounts.length) { + showToast('没有解析到有效账号,请检查格式是否为 账号----密码----ID----Token。', 'error'); + return; + } + + hotmailActionInFlight = true; + btnImportHotmailAccounts.disabled = true; + + try { + for (const account of parsedAccounts) { + const response = await chrome.runtime.sendMessage({ + type: 'UPSERT_HOTMAIL_ACCOUNT', + source: 'sidepanel', + payload: account, + }); + if (response?.error) { + throw new Error(response.error); + } + } + + inputHotmailImport.value = ''; + showToast(`已导入 ${parsedAccounts.length} 条 Hotmail 账号`, 'success', 2200); + } catch (err) { + showToast(`批量导入失败:${err.message}`, 'error'); + } finally { + hotmailActionInFlight = false; + btnImportHotmailAccounts.disabled = false; + } +}); + +hotmailAccountsList?.addEventListener('click', async (event) => { + const actionButton = event.target.closest('[data-account-action]'); + if (!actionButton || hotmailActionInFlight) { + return; + } + + const accountId = actionButton.dataset.accountId; + const action = actionButton.dataset.accountAction; + if (!accountId || !action) { + return; + } + + const targetAccount = getHotmailAccounts().find((account) => account.id === accountId) || null; + + hotmailActionInFlight = true; + actionButton.disabled = true; + + try { + if (action === 'copy-email') { + if (!targetAccount?.email) throw new Error('未找到可复制的邮箱地址。'); + await copyTextToClipboard(targetAccount.email); + showToast(`已复制 ${targetAccount.email}`, 'success', 1800); + } else if (action === 'select') { + const response = await chrome.runtime.sendMessage({ + type: 'SELECT_HOTMAIL_ACCOUNT', + source: 'sidepanel', + payload: { accountId }, + }); + if (response?.error) throw new Error(response.error); + syncLatestState({ currentHotmailAccountId: response.account.id }); + applyHotmailAccountMutation(response.account, { preserveCurrentSelection: true }); + showToast(`已切换当前 Hotmail 账号为 ${response.account.email}`, 'success', 1800); + } else if (action === 'toggle-used') { + if (!targetAccount) throw new Error('未找到目标 Hotmail 账号。'); + const response = await chrome.runtime.sendMessage({ + type: 'PATCH_HOTMAIL_ACCOUNT', + source: 'sidepanel', + payload: { + accountId, + updates: { used: !targetAccount.used }, + }, + }); + if (response?.error) throw new Error(response.error); + applyHotmailAccountMutation(response.account); + showToast(`账号 ${response.account.email} 已${response.account.used ? '标记为已用' : '恢复为未用'}`, 'success', 2200); + } else if (action === 'verify') { + const response = await chrome.runtime.sendMessage({ + type: 'VERIFY_HOTMAIL_ACCOUNT', + source: 'sidepanel', + payload: { accountId }, + }); + if (response?.error) throw new Error(response.error); + applyHotmailAccountMutation(response.account, { preserveCurrentSelection: true }); + showToast(`账号 ${response.account.email} 校验通过`, 'success', 2200); + } else if (action === 'test') { + const response = await chrome.runtime.sendMessage({ + type: 'TEST_HOTMAIL_ACCOUNT', + source: 'sidepanel', + payload: { accountId }, + }); + if (response?.error) throw new Error(response.error); + applyHotmailAccountMutation(response.account, { preserveCurrentSelection: true }); + if (response.latestCode) { + await copyTextToClipboard(response.latestCode); + const mailbox = response.latestMailbox ? `(${response.latestMailbox})` : ''; + showToast(`已复制最新验证码 ${response.latestCode}${mailbox}`, 'success', 2600); + } else if (response.latestSubject) { + const mailbox = response.latestMailbox ? `(${response.latestMailbox})` : ''; + showToast(`最新邮件${mailbox}没有验证码:${response.latestSubject}`, 'warn', 3200); + } else { + showToast('当前没有可读取的最新邮件。', 'warn', 2600); + } + } else if (action === 'delete') { + const confirmed = await openConfirmModal({ + title: '删除账号', + message: '确认删除这个 Hotmail 账号吗?对应 token 也会一起移除。', + confirmLabel: '确认删除', + confirmVariant: 'btn-danger', + }); + if (!confirmed) { + return; + } + const response = await chrome.runtime.sendMessage({ + type: 'DELETE_HOTMAIL_ACCOUNT', + source: 'sidepanel', + payload: { accountId }, + }); + if (response?.error) throw new Error(response.error); + showToast('Hotmail 账号已删除', 'success', 1800); + } + } catch (err) { + showToast(err.message, 'error'); + } finally { + hotmailActionInFlight = false; + actionButton.disabled = false; + } +}); + btnTogglePassword.addEventListener('click', () => { inputPassword.type = inputPassword.type === 'password' ? 'text' : 'password'; syncPasswordToggleLabel(); @@ -869,7 +1420,7 @@ btnReset.addEventListener('click', async () => { } await chrome.runtime.sendMessage({ type: 'RESET', source: 'sidepanel' }); - syncLatestState({ stepStatuses: STEP_DEFAULT_STATUSES }); + syncLatestState({ stepStatuses: STEP_DEFAULT_STATUSES, currentHotmailAccountId: null, email: null }); syncAutoRunState({ autoRunning: false, autoRunPhase: 'idle', autoRunCurrentRun: 0, autoRunTotalRuns: 1, autoRunAttemptRun: 0 }); displayOauthUrl.textContent = '等待中...'; displayOauthUrl.classList.remove('has-value'); @@ -887,6 +1438,7 @@ btnReset.addEventListener('click', async () => { updateStopButtonState(false); updateButtonStates(); updateProgressCounter(); + renderHotmailAccounts(); }); // Clear log @@ -896,6 +1448,9 @@ btnClearLog.addEventListener('click', () => { // Save settings on change inputEmail.addEventListener('change', async () => { + if (selectMailProvider.value === 'hotmail-api') { + return; + } const email = inputEmail.value.trim(); if (email) { await chrome.runtime.sendMessage({ type: 'SAVE_EMAIL', source: 'sidepanel', payload: { email } }); @@ -1014,24 +1569,32 @@ chrome.runtime.onMessage.addListener((message) => { applyAutoRunStatus(currentAutoRun); updateProgressCounter(); updateButtonStates(); + renderHotmailAccounts(); break; } case 'DATA_UPDATED': { syncLatestState(message.payload); - if (message.payload.email) { - inputEmail.value = message.payload.email; + if (message.payload.email !== undefined) { + inputEmail.value = message.payload.email || ''; } if (message.payload.password !== undefined) { inputPassword.value = message.payload.password || ''; } - if (message.payload.oauthUrl) { - displayOauthUrl.textContent = message.payload.oauthUrl; - displayOauthUrl.classList.add('has-value'); + if (message.payload.oauthUrl !== undefined) { + displayOauthUrl.textContent = message.payload.oauthUrl || '等待中...'; + displayOauthUrl.classList.toggle('has-value', Boolean(message.payload.oauthUrl)); } - if (message.payload.localhostUrl) { - displayLocalhostUrl.textContent = message.payload.localhostUrl; - displayLocalhostUrl.classList.add('has-value'); + if (message.payload.localhostUrl !== undefined) { + displayLocalhostUrl.textContent = message.payload.localhostUrl || '等待中...'; + displayLocalhostUrl.classList.toggle('has-value', Boolean(message.payload.localhostUrl)); + } + if (message.payload.currentHotmailAccountId !== undefined || message.payload.hotmailAccounts !== undefined) { + renderHotmailAccounts(); + if (selectMailProvider.value === 'hotmail-api') { + const currentAccount = getCurrentHotmailAccount(); + inputEmail.value = currentAccount?.email || latestState?.email || ''; + } } break; } @@ -1083,6 +1646,7 @@ btnTheme.addEventListener('click', () => { initializeManualStepActions(); initTheme(); +initHotmailListExpandedState(); updateSaveButtonState(); restoreState().then(() => { syncPasswordToggleLabel(); diff --git a/tests/activation-utils.test.js b/tests/activation-utils.test.js new file mode 100644 index 0000000..37632a0 --- /dev/null +++ b/tests/activation-utils.test.js @@ -0,0 +1,83 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + getActivationStrategy, + isRecoverableStep9AuthFailure, +} = require('../content/activation-utils.js'); + +test('getActivationStrategy prefers requestSubmit for submit buttons inside forms', () => { + assert.deepEqual( + getActivationStrategy({ + tagName: 'button', + type: 'submit', + hasForm: true, + pathname: '/email-verification', + }), + { method: 'requestSubmit' } + ); +}); + +test('getActivationStrategy uses native click for non-submit actions', () => { + assert.deepEqual( + getActivationStrategy({ + tagName: 'button', + type: 'button', + hasForm: true, + }), + { method: 'click' } + ); + + assert.deepEqual( + getActivationStrategy({ + tagName: 'a', + type: '', + hasForm: false, + }), + { method: 'click' } + ); +}); + +test('getActivationStrategy only uses requestSubmit on email verification routes', () => { + assert.deepEqual( + getActivationStrategy({ + tagName: 'button', + type: 'submit', + hasForm: true, + pathname: '/u/signup/details', + }), + { method: 'click' } + ); + + assert.deepEqual( + getActivationStrategy({ + tagName: 'button', + type: 'submit', + hasForm: true, + pathname: '/email-verification', + }), + { method: 'requestSubmit' } + ); +}); + +test('isRecoverableStep9AuthFailure matches timeout and CPA auth failure statuses', () => { + assert.equal( + isRecoverableStep9AuthFailure('认证失败: Timeout waiting for OAuth callback'), + true + ); + + assert.equal( + isRecoverableStep9AuthFailure('认证失败: Request failed with status code 502'), + true + ); + + assert.equal( + isRecoverableStep9AuthFailure('认证成功!'), + false + ); + + assert.equal( + isRecoverableStep9AuthFailure('等待中'), + false + ); +}); diff --git a/tests/hotmail-utils.test.js b/tests/hotmail-utils.test.js new file mode 100644 index 0000000..fe5ea98 --- /dev/null +++ b/tests/hotmail-utils.test.js @@ -0,0 +1,468 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + buildHotmailMailApiLatestUrl, + extractVerificationCodeFromMessage, + filterHotmailAccountsByUsage, + extractVerificationCode, + getLatestHotmailMessage, + getHotmailBulkActionLabel, + getHotmailListToggleLabel, + getHotmailMailApiRequestConfig, + getHotmailVerificationPollConfig, + getHotmailVerificationRequestTimestamp, + normalizeHotmailMailApiMessages, + parseHotmailImportText, + pickHotmailAccountForRun, + pickVerificationMessage, + pickVerificationMessageWithFallback, + pickVerificationMessageWithTimeFallback, + shouldClearHotmailCurrentSelection, + upsertHotmailAccountInList, +} = require('../hotmail-utils.js'); + +test('pickHotmailAccountForRun prefers authorized account with oldest lastUsedAt', () => { + const now = Date.UTC(2026, 3, 10, 10, 0, 0); + const accounts = [ + { + id: 'recent', + email: 'recent@hotmail.com', + status: 'authorized', + refreshToken: 'rt-recent', + lastUsedAt: now - 1_000, + }, + { + id: 'oldest', + email: 'oldest@hotmail.com', + status: 'authorized', + refreshToken: 'rt-oldest', + lastUsedAt: now - 50_000, + }, + { + id: 'pending', + email: 'pending@hotmail.com', + status: 'pending', + refreshToken: '', + lastUsedAt: 0, + }, + ]; + + const selected = pickHotmailAccountForRun(accounts, { now }); + + assert.equal(selected.id, 'oldest'); +}); + +test('pickHotmailAccountForRun skips used accounts but ignores legacy enabled flag', () => { + const accounts = [ + { + id: 'disabled', + email: 'disabled@hotmail.com', + status: 'authorized', + refreshToken: 'rt-disabled', + enabled: false, + used: false, + lastUsedAt: 1, + }, + { + id: 'used', + email: 'used@hotmail.com', + status: 'authorized', + refreshToken: 'rt-used', + enabled: true, + used: true, + lastUsedAt: 0, + }, + { + id: 'available', + email: 'available@hotmail.com', + status: 'authorized', + refreshToken: 'rt-available', + enabled: true, + used: false, + lastUsedAt: 2, + }, + ]; + + const selected = pickHotmailAccountForRun(accounts, {}); + + assert.equal(selected.id, 'disabled'); +}); + +test('pickHotmailAccountForRun returns null for used-only pools', () => { + assert.equal( + pickHotmailAccountForRun([{ + id: 'used-only', + email: 'used-only@hotmail.com', + status: 'authorized', + refreshToken: 'rt-used-only', + used: true, + lastUsedAt: 0, + }], {}), + null + ); +}); + +test('pickHotmailAccountForRun falls back to never-used authorized account first', () => { + const accounts = [ + { + id: 'used', + email: 'used@hotmail.com', + status: 'authorized', + refreshToken: 'rt-used', + lastUsedAt: Date.UTC(2026, 3, 10, 9, 0, 0), + }, + { + id: 'fresh', + email: 'fresh@hotmail.com', + status: 'authorized', + refreshToken: 'rt-fresh', + lastUsedAt: 0, + }, + ]; + + const selected = pickHotmailAccountForRun(accounts, { now: Date.UTC(2026, 3, 10, 10, 0, 0) }); + + assert.equal(selected.id, 'fresh'); +}); + +test('upsertHotmailAccountInList replaces matching account state by id', () => { + const accounts = [ + { + id: 'active', + email: 'active@hotmail.com', + status: 'authorized', + used: false, + }, + { + id: 'other', + email: 'other@hotmail.com', + status: 'authorized', + used: false, + }, + ]; + + const nextAccounts = upsertHotmailAccountInList(accounts, { + id: 'active', + email: 'active@hotmail.com', + status: 'authorized', + used: true, + }); + + assert.deepEqual(nextAccounts, [ + { + id: 'active', + email: 'active@hotmail.com', + status: 'authorized', + used: true, + }, + { + id: 'other', + email: 'other@hotmail.com', + status: 'authorized', + used: false, + }, + ]); +}); + +test('shouldClearHotmailCurrentSelection returns true only when account becomes used', () => { + assert.equal(shouldClearHotmailCurrentSelection({ + id: 'used', + used: true, + }), true); + + assert.equal(shouldClearHotmailCurrentSelection({ + id: 'available', + used: false, + }), false); +}); + +test('extractVerificationCode returns first six-digit code from multilingual mail text', () => { + assert.equal(extractVerificationCode('你的 ChatGPT 验证码为 370794,请勿泄露。'), '370794'); + assert.equal(extractVerificationCode('Your verification code is 654321.'), '654321'); + assert.equal(extractVerificationCode('No code here'), null); +}); + +test('extractVerificationCodeFromMessage reads code from the latest message subject or preview', () => { + assert.equal( + extractVerificationCodeFromMessage({ + subject: '你的 ChatGPT 代码为 192742', + bodyPreview: 'OpenAI 验证邮件', + from: { emailAddress: { address: 'noreply@openai.com' } }, + }), + '192742' + ); + + assert.equal( + extractVerificationCodeFromMessage({ + subject: 'OpenAI security message', + bodyPreview: 'Your verification code is 654321.', + from: { emailAddress: { address: 'noreply@openai.com' } }, + }), + '654321' + ); +}); + +test('getHotmailListToggleLabel reflects expanded state and account count', () => { + assert.equal(getHotmailListToggleLabel(false, 0), '展开列表'); + assert.equal(getHotmailListToggleLabel(false, 7), '展开列表(7)'); + assert.equal(getHotmailListToggleLabel(true, 7), '收起列表(7)'); +}); + +test('filterHotmailAccountsByUsage can pick only used accounts or return all accounts', () => { + const accounts = [ + { id: 'used-1', email: 'used-1@hotmail.com', used: true }, + { id: 'fresh-1', email: 'fresh-1@hotmail.com', used: false }, + { id: 'used-2', email: 'used-2@hotmail.com', used: true }, + ]; + + assert.deepEqual( + filterHotmailAccountsByUsage(accounts, 'used').map((account) => account.id), + ['used-1', 'used-2'] + ); + + assert.deepEqual( + filterHotmailAccountsByUsage(accounts, 'all').map((account) => account.id), + ['used-1', 'fresh-1', 'used-2'] + ); +}); + +test('getHotmailBulkActionLabel reflects action type and count', () => { + assert.equal(getHotmailBulkActionLabel('used', 0), '清空已用'); + assert.equal(getHotmailBulkActionLabel('used', 3), '清空已用(3)'); + assert.equal(getHotmailBulkActionLabel('all', 5), '全部删除(5)'); +}); + +test('getLatestHotmailMessage picks the newest received mail', () => { + const latest = getLatestHotmailMessage([ + { + id: 'older', + subject: 'older', + receivedDateTime: '2026-04-11T00:01:00.000Z', + }, + { + id: 'newest', + subject: 'newest', + receivedDateTime: '2026-04-11T00:05:00.000Z', + }, + { + id: 'middle', + subject: 'middle', + receivedDateTime: '2026-04-11T00:03:00.000Z', + }, + ]); + + assert.equal(latest.id, 'newest'); +}); + +test('pickVerificationMessage filters by time, sender, subject, and excluded codes', () => { + const messages = [ + { + id: 'old-mail', + subject: 'Your code is 111111', + from: { emailAddress: { address: 'noreply@openai.com' } }, + bodyPreview: '111111', + receivedDateTime: '2026-04-10T09:00:00.000Z', + }, + { + id: 'wrong-sender', + subject: 'Your code is 222222', + from: { emailAddress: { address: 'noreply@example.com' } }, + bodyPreview: '222222', + receivedDateTime: '2026-04-10T10:01:00.000Z', + }, + { + id: 'good-mail', + subject: 'ChatGPT verification code 333333', + from: { emailAddress: { address: 'noreply@openai.com' } }, + bodyPreview: 'Use 333333 to continue', + receivedDateTime: '2026-04-10T10:02:00.000Z', + }, + { + id: 'excluded-mail', + subject: 'ChatGPT verification code 444444', + from: { emailAddress: { address: 'noreply@openai.com' } }, + bodyPreview: 'Use 444444 to continue', + receivedDateTime: '2026-04-10T10:03:00.000Z', + }, + ]; + + const match = pickVerificationMessage(messages, { + afterTimestamp: Date.UTC(2026, 3, 10, 10, 0, 0), + senderFilters: ['openai', 'noreply'], + subjectFilters: ['verification', 'code', 'chatgpt'], + excludeCodes: ['444444'], + }); + + assert.equal(match.message.id, 'good-mail'); + assert.equal(match.code, '333333'); +}); + +test('pickVerificationMessageWithFallback returns relaxed match when strict filters miss but recent code exists', () => { + const messages = [ + { + id: 'login-mail', + subject: 'Use this security code to continue 555666', + from: { emailAddress: { address: 'account-security@openai.com' } }, + bodyPreview: 'Your one-time security code is 555666', + receivedDateTime: '2026-04-10T10:05:00.000Z', + }, + ]; + + const result = pickVerificationMessageWithFallback(messages, { + afterTimestamp: Date.UTC(2026, 3, 10, 10, 0, 0), + senderFilters: ['noreply'], + subjectFilters: ['verification'], + excludeCodes: [], + }); + + assert.equal(result.match.message.id, 'login-mail'); + assert.equal(result.match.code, '555666'); + assert.equal(result.usedRelaxedFilters, true); + assert.equal(result.usedTimeFallback, false); +}); + +test('pickVerificationMessageWithTimeFallback can ignore afterTimestamp when latest code mail is just outside flow window', () => { + const messages = [ + { + id: 'slightly-old-mail', + subject: '你的 ChatGPT 代码为 141735', + from: { emailAddress: { address: 'unknown' } }, + bodyPreview: 'OpenAI logo ...', + receivedDateTime: '2026-04-10T10:00:02.000Z', + }, + ]; + + const result = pickVerificationMessageWithTimeFallback(messages, { + afterTimestamp: Date.UTC(2026, 3, 10, 10, 0, 10), + senderFilters: ['openai', 'noreply'], + subjectFilters: ['verify', 'verification', 'code'], + excludeCodes: [], + }); + + assert.equal(result.match.message.id, 'slightly-old-mail'); + assert.equal(result.match.code, '141735'); + assert.equal(result.usedRelaxedFilters, true); + assert.equal(result.usedTimeFallback, true); +}); + +test('buildHotmailMailApiLatestUrl includes email, client id, refresh token, and mailbox', () => { + const url = new URL(buildHotmailMailApiLatestUrl({ + clientId: 'client-123', + email: 'user@hotmail.com', + refreshToken: 'refresh-token-xyz', + mailbox: 'Junk', + })); + + assert.equal(url.origin + url.pathname, 'https://apple.882263.xyz/api/mail-new'); + assert.equal(url.searchParams.get('client_id'), 'client-123'); + assert.equal(url.searchParams.get('email'), 'user@hotmail.com'); + assert.equal(url.searchParams.get('refresh_token'), 'refresh-token-xyz'); + assert.equal(url.searchParams.get('mailbox'), 'Junk'); + assert.equal(url.searchParams.get('response_type'), 'json'); +}); + +test('normalizeHotmailMailApiMessages maps third-party payload fields into verification message shape', () => { + const messages = normalizeHotmailMailApiMessages([ + { + id: 'mail-1', + from: 'noreply@openai.com', + subject: 'ChatGPT verification code', + text: 'Use 135790 to continue', + date: '2026-04-10T10:02:00.000Z', + }, + { + message_id: 'mail-2', + sender_email: 'alerts@example.com', + title: 'Ignored', + body: 'No code here', + received_at: '2026-04-10T10:03:00.000Z', + }, + ]); + + assert.deepEqual(messages, [ + { + id: 'mail-1', + subject: 'ChatGPT verification code', + from: { emailAddress: { address: 'noreply@openai.com' } }, + bodyPreview: 'Use 135790 to continue', + receivedDateTime: '2026-04-10T10:02:00.000Z', + }, + { + id: 'mail-2', + subject: 'Ignored', + from: { emailAddress: { address: 'alerts@example.com' } }, + bodyPreview: 'No code here', + receivedDateTime: '2026-04-10T10:03:00.000Z', + }, + ]); +}); + +test('getHotmailVerificationPollConfig gives Hotmail a slower initial wait and longer polling window', () => { + assert.deepEqual(getHotmailVerificationPollConfig(4), { + initialDelayMs: 5000, + maxAttempts: 12, + intervalMs: 5000, + requestFreshCodeFirst: false, + ignorePersistedLastCode: true, + }); + + assert.deepEqual(getHotmailVerificationPollConfig(7), { + initialDelayMs: 5000, + maxAttempts: 12, + intervalMs: 5000, + requestFreshCodeFirst: false, + ignorePersistedLastCode: true, + }); +}); + +test('getHotmailVerificationRequestTimestamp prefers actual request timestamps with a safety buffer', () => { + const signupRequestedAt = Date.UTC(2026, 3, 10, 12, 0, 30); + const loginRequestedAt = Date.UTC(2026, 3, 10, 12, 5, 45); + + assert.equal( + getHotmailVerificationRequestTimestamp(4, { + signupVerificationRequestedAt: signupRequestedAt, + flowStartTime: signupRequestedAt - 60_000, + }), + signupRequestedAt - 15_000 + ); + + assert.equal( + getHotmailVerificationRequestTimestamp(7, { + loginVerificationRequestedAt: loginRequestedAt, + lastEmailTimestamp: loginRequestedAt - 120_000, + flowStartTime: loginRequestedAt - 300_000, + }), + loginRequestedAt - 15_000 + ); +}); + +test('getHotmailMailApiRequestConfig defines a finite timeout for mailbox polling', () => { + assert.deepEqual(getHotmailMailApiRequestConfig(), { + timeoutMs: 15000, + }); +}); + +test('parseHotmailImportText parses account lines in email----password----clientId----token format', () => { + const parsed = parseHotmailImportText(` +账号----密码----ID----Token +JohnRodriguez5425@hotmail.com----nb4ta1OK----9e5f94bc-e8a4-4e73-b8be-63364c29d753----refresh-token-1 +alice@hotmail.com----pass-2----client-2----refresh-token-2 + `.trim()); + + assert.deepEqual(parsed, [ + { + email: 'JohnRodriguez5425@hotmail.com', + password: 'nb4ta1OK', + clientId: '9e5f94bc-e8a4-4e73-b8be-63364c29d753', + refreshToken: 'refresh-token-1', + }, + { + email: 'alice@hotmail.com', + password: 'pass-2', + clientId: 'client-2', + refreshToken: 'refresh-token-2', + }, + ]); +});