diff --git a/.gitignore b/.gitignore index e3358c2..53386cf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /docs/md /.github +/_metadata/ diff --git a/README.md b/README.md index 87d60c3..b6aca3f 100644 --- a/README.md +++ b/README.md @@ -492,13 +492,27 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。 在登录前会先重新获取一遍最新的 CPA OAuth 链接,再使用刚注册的账号登录。 +当前 Step 6 的完成标准不是“邮箱/密码已提交”,而是: + +- 认证页已经真正进入登录验证码页面 +- 如遇登录超时报错或登录页长时间停滞,会由后台刷新 OAuth 后重跑整个 Step 6 + 支持: - 邮箱 + 密码登录 -- 提交后进入验证码验证流程 +- 必要时切换到一次性验证码登录 +- 直到登录验证码页就绪才算步骤完成 ### Step 7: Get Login Code +Step 7 默认要求当前认证页已经处于登录验证码页。 + +它只负责: + +- 打开邮箱并轮询登录验证码 +- 填写并提交登录验证码 +- 验证码链路失败后按有限次数回退到 Step 6 + 与 Step 4 类似,但会使用稍微不同的关键词组合去找登录验证码邮件。 ### Step 8: Manual OAuth Confirm diff --git a/background.js b/background.js index de90716..19b0caf 100644 --- a/background.js +++ b/background.js @@ -1,9 +1,16 @@ // background.js — Service Worker: orchestration, state, tab management, message routing -importScripts('data/names.js', 'hotmail-utils.js', 'microsoft-email.js', 'content/activation-utils.js'); +importScripts( + 'data/names.js', + 'hotmail-utils.js', + 'microsoft-email.js', + 'luckmail-utils.js', + 'cloudflare-temp-email-utils.js', + 'icloud-utils.js', + 'content/activation-utils.js' +); const { - buildHotmailMailApiLatestUrl, extractVerificationCodeFromMessage, filterHotmailAccountsByUsage, getLatestHotmailMessage, @@ -20,15 +27,75 @@ const { } = self.HotmailUtils; const { fetchMicrosoftMailboxMessages, - fetchMicrosoftVerificationCode, } = self.MultiPageMicrosoftEmail; +const { + DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME, + DEFAULT_LUCKMAIL_BASE_URL, + DEFAULT_LUCKMAIL_EMAIL_TYPE, + buildLuckmailBaselineCursor, + buildLuckmailMailCursor, + filterReusableLuckmailPurchases, + isLuckmailMailNewerThanCursor, + isLuckmailPurchaseReusable, + isLuckmailPurchaseForProject, + isLuckmailPurchasePreserved, + normalizeLuckmailBaseUrl, + normalizeLuckmailEmailType, + normalizeLuckmailMailCursor, + normalizeLuckmailProjectName, + normalizeLuckmailPurchase, + normalizeLuckmailPurchaseId, + normalizeLuckmailPurchaseListPage, + normalizeLuckmailPurchases, + normalizeLuckmailTags, + normalizeLuckmailTokenCode, + normalizeLuckmailTokenMail, + normalizeLuckmailTokenMails, + normalizeLuckmailUsedPurchases, + normalizeTimestamp: normalizeLuckmailTimestamp, + pickLuckmailVerificationMail, +} = self.LuckMailUtils; +const { + DEFAULT_MAIL_PAGE_SIZE: CLOUDFLARE_TEMP_EMAIL_DEFAULT_PAGE_SIZE, + buildCloudflareTempEmailHeaders, + getCloudflareTempEmailAddressFromResponse, + joinCloudflareTempEmailUrl, + normalizeCloudflareTempEmailAddress, + normalizeCloudflareTempEmailBaseUrl, + normalizeCloudflareTempEmailDomain, + normalizeCloudflareTempEmailDomains, + normalizeCloudflareTempEmailMailApiMessages, +} = self.CloudflareTempEmailUtils; +const { + findIcloudAliasByEmail, + getConfiguredIcloudHostPreference, + getIcloudHostHintFromMessage, + getIcloudLoginUrlForHost, + getIcloudSetupUrlForHost, + normalizeBooleanMap, + normalizeIcloudAliasList, + normalizeIcloudHost, + pickReusableIcloudAlias, + toNormalizedEmailSet, +} = self.IcloudUtils; const { isRecoverableStep9AuthFailure, } = self.MultiPageActivationUtils; const LOG_PREFIX = '[MultiPage:bg]'; const DUCK_AUTOFILL_URL = 'https://duckduckgo.com/email/settings/autofill'; +const ICLOUD_SETUP_URLS = [ + 'https://setup.icloud.com.cn/setup/ws/1', + 'https://setup.icloud.com/setup/ws/1', +]; +const ICLOUD_LOGIN_URLS = [ + 'https://www.icloud.com.cn/', + 'https://www.icloud.com/', +]; const HOTMAIL_PROVIDER = 'hotmail-api'; +const LUCKMAIL_PROVIDER = 'luckmail-api'; +const CLOUDFLARE_TEMP_EMAIL_PROVIDER = 'cloudflare-temp-email'; +const CLOUDFLARE_TEMP_EMAIL_GENERATOR = 'cloudflare-temp-email'; const HOTMAIL_MAILBOXES = ['INBOX', 'Junk']; const STOP_ERROR_MESSAGE = '流程已被用户停止。'; const HUMAN_STEP_DELAY_MIN = 700; @@ -48,13 +115,18 @@ const AUTO_STEP_DELAY_MIN_ALLOWED_SECONDS = 0; const AUTO_STEP_DELAY_MAX_ALLOWED_SECONDS = 600; const LEGACY_AUTO_STEP_DELAY_KEYS = ['autoStepRandomDelayMinSeconds', 'autoStepRandomDelayMaxSeconds']; const DEFAULT_LOCAL_CPA_STEP9_MODE = 'submit'; +const MAIL_2925_MODE_PROVIDE = 'provide'; +const MAIL_2925_MODE_RECEIVE = 'receive'; +const DEFAULT_MAIL_2925_MODE = MAIL_2925_MODE_PROVIDE; const HOTMAIL_SERVICE_MODE_REMOTE = 'remote'; const HOTMAIL_SERVICE_MODE_LOCAL = 'local'; const DEFAULT_HOTMAIL_REMOTE_BASE_URL = ''; const DEFAULT_HOTMAIL_LOCAL_BASE_URL = 'http://127.0.0.1:17373'; const HOTMAIL_LOCAL_HELPER_TIMEOUT_MS = 45000; +const DEFAULT_LUCKMAIL_PROJECT_CODE = 'openai'; const DISPLAY_TIMEZONE = 'Asia/Shanghai'; const MICROSOFT_TOKEN_DNR_RULE_ID = 1001; +const PERSISTENT_ALIAS_STATE_KEYS = ['manualAliasUsage', 'preservedAliases']; initializeSessionStorageAccess(); setupDeclarativeNetRequestRules(); @@ -105,7 +177,10 @@ const PERSISTED_SETTING_DEFAULTS = { autoRunDelayMinutes: 30, autoStepDelaySeconds: null, mailProvider: '163', + mail2925Mode: DEFAULT_MAIL_2925_MODE, emailGenerator: 'duck', + autoDeleteUsedIcloudAlias: false, + icloudHostPreference: 'auto', emailPrefix: '', inbucketHost: '', inbucketMailbox: '', @@ -114,6 +189,11 @@ const PERSISTED_SETTING_DEFAULTS = { hotmailLocalBaseUrl: DEFAULT_HOTMAIL_LOCAL_BASE_URL, cloudflareDomain: '', cloudflareDomains: [], + cloudflareTempEmailBaseUrl: '', + cloudflareTempEmailAdminAuth: '', + cloudflareTempEmailCustomAuth: '', + cloudflareTempEmailDomain: '', + cloudflareTempEmailDomains: [], hotmailAccounts: [], }; @@ -131,6 +211,8 @@ const DEFAULT_STATE = { email: null, // 运行时邮箱,由程序自动获取并写入,不能手动预填。 password: null, // 运行时实际密码,由 customPassword 或程序自动生成后写入。 accounts: [], // 已生成账号记录:{ email, password, createdAt }。 + manualAliasUsage: {}, + preservedAliases: {}, lastEmailTimestamp: null, // 最近一次获取到邮箱数据的运行时时间戳。 lastSignupCode: null, // 注册验证码,运行时由程序自动读取并写入。 lastLoginCode: null, // 登录验证码,运行时由程序自动读取并写入。 @@ -144,6 +226,15 @@ const DEFAULT_STATE = { sourceLastUrls: {}, // 各来源页面最近一次打开的地址记录。 logs: [], // 侧边栏展示的运行日志。 ...PERSISTED_SETTING_DEFAULTS, // 合并 chrome.storage.local 中持久化保存的用户配置。 + luckmailApiKey: '', + luckmailBaseUrl: DEFAULT_LUCKMAIL_BASE_URL, + luckmailEmailType: DEFAULT_LUCKMAIL_EMAIL_TYPE, + luckmailDomain: '', + luckmailUsedPurchases: {}, + luckmailPreserveTagId: 0, + luckmailPreserveTagName: DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME, + currentLuckmailPurchase: null, + currentLuckmailMailCursor: null, autoRunning: false, // 当前是否处于自动运行中。 autoRunPhase: 'idle', // 当前自动运行阶段。 autoRunCurrentRun: 0, // 自动运行当前执行到第几轮。 @@ -158,6 +249,7 @@ const DEFAULT_STATE = { signupVerificationRequestedAt: null, loginVerificationRequestedAt: null, currentHotmailAccountId: null, + preferredIcloudHost: '', }; function normalizeAutoRunDelayMinutes(value) { @@ -251,9 +343,11 @@ function normalizeEmailGenerator(value = '') { if (normalized === 'custom' || normalized === 'manual') { return 'custom'; } - if (normalized === 'cloudflare') { - return 'cloudflare'; + if (normalized === 'icloud') { + return 'icloud'; } + if (normalized === 'cloudflare') return 'cloudflare'; + if (normalized === CLOUDFLARE_TEMP_EMAIL_GENERATOR) return CLOUDFLARE_TEMP_EMAIL_GENERATOR; return 'duck'; } @@ -266,6 +360,8 @@ function normalizeMailProvider(value = '') { switch (normalized) { case 'custom': case HOTMAIL_PROVIDER: + case LUCKMAIL_PROVIDER: + case CLOUDFLARE_TEMP_EMAIL_PROVIDER: case '163': case '163-vip': case 'qq': @@ -277,6 +373,54 @@ function normalizeMailProvider(value = '') { } } +function buildLuckmailSessionSettingsPayload(input = {}) { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return {}; + } + + const payload = {}; + + if (input.luckmailApiKey !== undefined) { + payload.luckmailApiKey = String(input.luckmailApiKey || ''); + } + if (input.luckmailBaseUrl !== undefined) { + payload.luckmailBaseUrl = normalizeLuckmailBaseUrl(input.luckmailBaseUrl); + } + if (input.luckmailEmailType !== undefined) { + payload.luckmailEmailType = normalizeLuckmailEmailType(input.luckmailEmailType); + } + if (input.luckmailDomain !== undefined) { + payload.luckmailDomain = String(input.luckmailDomain || '').trim(); + } + if (input.luckmailUsedPurchases !== undefined) { + payload.luckmailUsedPurchases = normalizeLuckmailUsedPurchases(input.luckmailUsedPurchases); + } + if (input.luckmailPreserveTagId !== undefined) { + payload.luckmailPreserveTagId = Number(input.luckmailPreserveTagId) || 0; + } + if (input.luckmailPreserveTagName !== undefined) { + payload.luckmailPreserveTagName = String(input.luckmailPreserveTagName || '').trim() || DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME; + } + if (input.currentLuckmailPurchase !== undefined) { + payload.currentLuckmailPurchase = input.currentLuckmailPurchase + ? normalizeLuckmailPurchase(input.currentLuckmailPurchase) + : null; + } + if (input.currentLuckmailMailCursor !== undefined) { + payload.currentLuckmailMailCursor = input.currentLuckmailMailCursor + ? normalizeLuckmailMailCursor(input.currentLuckmailMailCursor) + : null; + } + + return payload; +} + +function normalizeMail2925Mode(value = '') { + return String(value || '').trim().toLowerCase() === MAIL_2925_MODE_RECEIVE + ? MAIL_2925_MODE_RECEIVE + : DEFAULT_MAIL_2925_MODE; +} + function normalizeLocalCpaStep9Mode(value = '') { return String(value || '').trim().toLowerCase() === 'bypass' ? 'bypass' @@ -359,6 +503,16 @@ function getHotmailServiceSettings(state = {}) { }; } +function getCloudflareTempEmailConfig(state = {}) { + return { + baseUrl: normalizeCloudflareTempEmailBaseUrl(state.cloudflareTempEmailBaseUrl), + adminAuth: String(state.cloudflareTempEmailAdminAuth || ''), + customAuth: String(state.cloudflareTempEmailCustomAuth || ''), + domain: normalizeCloudflareTempEmailDomain(state.cloudflareTempEmailDomain), + domains: normalizeCloudflareTempEmailDomains(state.cloudflareTempEmailDomains), + }; +} + function normalizePersistentSettingValue(key, value) { switch (key) { case 'panelMode': @@ -390,8 +544,14 @@ function normalizePersistentSettingValue(key, value) { return normalizeAutoStepDelaySeconds(value, PERSISTED_SETTING_DEFAULTS.autoStepDelaySeconds); case 'mailProvider': return normalizeMailProvider(value); + case 'mail2925Mode': + return normalizeMail2925Mode(value); case 'emailGenerator': return normalizeEmailGenerator(value); + case 'autoDeleteUsedIcloudAlias': + return Boolean(value); + case 'icloudHostPreference': + return normalizeIcloudHost(value) || 'auto'; case 'emailPrefix': return String(value || '').trim(); case 'inbucketHost': @@ -408,6 +568,15 @@ function normalizePersistentSettingValue(key, value) { return normalizeCloudflareDomain(value); case 'cloudflareDomains': return normalizeCloudflareDomains(value); + case 'cloudflareTempEmailBaseUrl': + return normalizeCloudflareTempEmailBaseUrl(value); + case 'cloudflareTempEmailAdminAuth': + case 'cloudflareTempEmailCustomAuth': + return String(value || ''); + case 'cloudflareTempEmailDomain': + return normalizeCloudflareTempEmailDomain(value); + case 'cloudflareTempEmailDomains': + return normalizeCloudflareTempEmailDomains(value); case 'hotmailAccounts': return normalizeHotmailAccounts(value); default: @@ -451,6 +620,13 @@ function buildPersistentSettingsPayload(input = {}, options = {}) { } payload.cloudflareDomains = domains; } + if (payload.cloudflareTempEmailDomains) { + const domains = normalizeCloudflareTempEmailDomains(payload.cloudflareTempEmailDomains); + if (payload.cloudflareTempEmailDomain && !domains.includes(payload.cloudflareTempEmailDomain)) { + domains.unshift(payload.cloudflareTempEmailDomain); + } + payload.cloudflareTempEmailDomains = domains; + } return payload; } @@ -460,12 +636,29 @@ async function getPersistedSettings() { return buildPersistentSettingsPayload(stored, { fillDefaults: true }); } +async function getPersistedAliasState() { + try { + const stored = await chrome.storage.local.get(PERSISTENT_ALIAS_STATE_KEYS); + return { + manualAliasUsage: normalizeBooleanMap(stored.manualAliasUsage), + preservedAliases: normalizeBooleanMap(stored.preservedAliases), + }; + } catch (err) { + console.warn(LOG_PREFIX, 'Failed to read persisted iCloud alias state:', err?.message || err); + return { + manualAliasUsage: {}, + preservedAliases: {}, + }; + } +} + async function getState() { - const [state, persistedSettings] = await Promise.all([ + const [state, persistedSettings, persistedAliasState] = await Promise.all([ chrome.storage.session.get(null), getPersistedSettings(), + getPersistedAliasState(), ]); - return { ...DEFAULT_STATE, ...persistedSettings, ...state }; + return { ...DEFAULT_STATE, ...persistedSettings, ...persistedAliasState, ...state }; } async function initializeSessionStorageAccess() { @@ -485,6 +678,16 @@ async function setState(updates) { console.log(LOG_PREFIX, 'storage.set:', JSON.stringify(updates).slice(0, 200)); if (Object.keys(updates || {}).length > 0) { await chrome.storage.session.set(updates); + const persistentAliasUpdates = {}; + if (Object.prototype.hasOwnProperty.call(updates, 'manualAliasUsage')) { + persistentAliasUpdates.manualAliasUsage = normalizeBooleanMap(updates.manualAliasUsage); + } + if (Object.prototype.hasOwnProperty.call(updates, 'preservedAliases')) { + persistentAliasUpdates.preservedAliases = normalizeBooleanMap(updates.preservedAliases); + } + if (Object.keys(persistentAliasUpdates).length > 0) { + await chrome.storage.local.set(persistentAliasUpdates); + } } } @@ -563,6 +766,13 @@ function broadcastDataUpdate(payload) { }).catch(() => { }); } +function broadcastIcloudAliasesChanged(payload = {}) { + chrome.runtime.sendMessage({ + type: 'ICLOUD_ALIASES_CHANGED', + payload, + }).catch(() => { }); +} + async function setEmailStateSilently(email) { await setState({ email }); broadcastDataUpdate({ email }); @@ -580,28 +790,180 @@ async function setPasswordState(password) { broadcastDataUpdate({ password }); } +function getLuckmailUsedPurchases(state = {}) { + return normalizeLuckmailUsedPurchases(state?.luckmailUsedPurchases); +} + +function getLuckmailPreserveTagInfo(state = {}) { + return { + id: Number(state?.luckmailPreserveTagId) || 0, + name: String(state?.luckmailPreserveTagName || '').trim() || DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME, + }; +} + +async function setLuckmailUsedPurchasesState(usedPurchases) { + const normalizedUsedPurchases = normalizeLuckmailUsedPurchases(usedPurchases); + await setState({ luckmailUsedPurchases: normalizedUsedPurchases }); + broadcastDataUpdate({ luckmailUsedPurchases: normalizedUsedPurchases }); + return normalizedUsedPurchases; +} + +async function setLuckmailPurchaseUsedState(purchaseId, used) { + const normalizedPurchaseId = normalizeLuckmailPurchaseId(purchaseId); + if (!normalizedPurchaseId) { + throw new Error('LuckMail 邮箱 ID 无效。'); + } + + const state = await getState(); + const usedPurchases = getLuckmailUsedPurchases(state); + if (used) { + usedPurchases[normalizedPurchaseId] = true; + } else { + delete usedPurchases[normalizedPurchaseId]; + } + + await setLuckmailUsedPurchasesState(usedPurchases); + return { + purchaseId: Number(normalizedPurchaseId), + used: Boolean(used), + }; +} + +async function setLuckmailPreserveTagInfo(tag) { + const normalizedTags = normalizeLuckmailTags([tag]); + const normalizedTag = normalizedTags[0] || { + id: 0, + name: DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME, + }; + const updates = { + luckmailPreserveTagId: Number(normalizedTag.id) || 0, + luckmailPreserveTagName: String(normalizedTag.name || '').trim() || DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME, + }; + await setState(updates); + broadcastDataUpdate(updates); + return updates; +} + +async function setLuckmailPurchaseState(purchase) { + const normalizedPurchase = purchase ? normalizeLuckmailPurchase(purchase) : null; + await setState({ currentLuckmailPurchase: normalizedPurchase }); + broadcastDataUpdate({ currentLuckmailPurchase: normalizedPurchase }); + return normalizedPurchase; +} + +async function setLuckmailMailCursorState(cursor) { + const normalizedCursor = cursor ? normalizeLuckmailMailCursor(cursor) : null; + await setState({ currentLuckmailMailCursor: normalizedCursor }); + return normalizedCursor; +} + +async function clearLuckmailRuntimeState(options = {}) { + const { clearEmail = false } = options; + const updates = { + currentLuckmailPurchase: null, + currentLuckmailMailCursor: null, + }; + if (clearEmail) { + updates.email = null; + } + await setState(updates); + broadcastDataUpdate(updates); +} + +function getManualAliasUsageMap(state) { + return normalizeBooleanMap(state?.manualAliasUsage); +} + +function getPreservedAliasMap(state) { + return normalizeBooleanMap(state?.preservedAliases); +} + +function isAliasPreserved(state, email) { + const normalizedEmail = String(email || '').trim().toLowerCase(); + if (!normalizedEmail) return false; + return Boolean(getPreservedAliasMap(state)[normalizedEmail]); +} + +function getEffectiveUsedEmails(state) { + return toNormalizedEmailSet(getManualAliasUsageMap(state)); +} + +async function setIcloudAliasUsedState(payload = {}, options = {}) { + const email = String(payload.email || '').trim().toLowerCase(); + if (!email) { + throw new Error('未提供 iCloud 隐私邮箱地址。'); + } + + const used = Boolean(payload.used); + const state = await getState(); + const manualAliasUsage = getManualAliasUsageMap(state); + manualAliasUsage[email] = used; + await setState({ manualAliasUsage }); + if (!options.silentLog) { + await addLog(`iCloud:已将 ${email} 标记为${used ? '已用' : '未用'}`, 'ok'); + } + broadcastIcloudAliasesChanged({ reason: 'used-updated', email, used }); + return { email, used }; +} + +async function setIcloudAliasPreservedState(payload = {}) { + const email = String(payload.email || '').trim().toLowerCase(); + if (!email) { + throw new Error('未提供 iCloud 隐私邮箱地址。'); + } + + const preserved = Boolean(payload.preserved); + const state = await getState(); + const preservedAliases = getPreservedAliasMap(state); + preservedAliases[email] = preserved; + await setState({ preservedAliases }); + await addLog(`iCloud:已将 ${email} ${preserved ? '设为保留' : '取消保留'}`, 'ok'); + broadcastIcloudAliasesChanged({ reason: 'preserved-updated', email, preserved }); + return { email, preserved }; +} + async function resetState() { console.log(LOG_PREFIX, 'Resetting all state'); // Preserve settings and persistent data across resets - const [prev, persistedSettings] = await Promise.all([ + const [prev, persistedSettings, persistedAliasState] = await Promise.all([ chrome.storage.session.get([ 'seenCodes', 'seenInbucketMailIds', 'accounts', 'tabRegistry', 'sourceLastUrls', + 'luckmailApiKey', + 'luckmailBaseUrl', + 'luckmailEmailType', + 'luckmailDomain', + 'luckmailUsedPurchases', + 'luckmailPreserveTagId', + 'luckmailPreserveTagName', + 'preferredIcloudHost', ]), getPersistedSettings(), + getPersistedAliasState(), ]); await chrome.storage.session.clear(); await chrome.storage.session.set({ ...DEFAULT_STATE, ...persistedSettings, + ...persistedAliasState, seenCodes: prev.seenCodes || [], seenInbucketMailIds: prev.seenInbucketMailIds || [], accounts: prev.accounts || [], tabRegistry: prev.tabRegistry || {}, sourceLastUrls: prev.sourceLastUrls || {}, + luckmailApiKey: String(prev.luckmailApiKey || ''), + luckmailBaseUrl: normalizeLuckmailBaseUrl(prev.luckmailBaseUrl), + luckmailEmailType: normalizeLuckmailEmailType(prev.luckmailEmailType), + luckmailDomain: String(prev.luckmailDomain || '').trim(), + luckmailUsedPurchases: normalizeLuckmailUsedPurchases(prev.luckmailUsedPurchases), + luckmailPreserveTagId: Number(prev.luckmailPreserveTagId) || 0, + luckmailPreserveTagName: String(prev.luckmailPreserveTagName || '').trim() || DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME, + currentLuckmailPurchase: null, + currentLuckmailMailCursor: null, + preferredIcloudHost: prev.preferredIcloudHost || '', }); } @@ -675,6 +1037,13 @@ function isHotmailProvider(stateOrProvider) { return provider === HOTMAIL_PROVIDER; } +function isLuckmailProvider(stateOrProvider) { + const provider = typeof stateOrProvider === 'string' + ? stateOrProvider + : stateOrProvider?.mailProvider; + return provider === LUCKMAIL_PROVIDER; +} + function isCustomMailProvider(stateOrProvider) { const provider = typeof stateOrProvider === 'string' ? stateOrProvider @@ -682,6 +1051,13 @@ function isCustomMailProvider(stateOrProvider) { return provider === 'custom'; } +function getMail2925Mode(stateOrMode) { + if (typeof stateOrMode === 'string') { + return normalizeMail2925Mode(stateOrMode); + } + return normalizeMail2925Mode(stateOrMode?.mail2925Mode); +} + async function syncHotmailAccounts(accounts) { const normalized = normalizeHotmailAccounts(accounts); await setPersistentSettings({ hotmailAccounts: normalized }); @@ -839,11 +1215,6 @@ async function ensureHotmailAccountForFlow(options = {}) { return setCurrentHotmailAccount(account.id, { markUsed, syncEmail: true }); } -function buildHotmailRemoteEndpoint(baseUrl, path) { - const normalizedBaseUrl = normalizeHotmailRemoteBaseUrl(baseUrl); - return new URL(path, `${normalizedBaseUrl}/`).toString(); -} - function buildHotmailLocalEndpoint(baseUrl, path) { const normalizedBaseUrl = normalizeHotmailLocalBaseUrl(baseUrl); return new URL(path, `${normalizedBaseUrl}/`).toString(); @@ -868,6 +1239,7 @@ async function requestHotmailRemoteMailbox(account, mailbox = 'INBOX') { const result = await fetchMicrosoftMailboxMessages({ clientId: account.clientId, refreshToken: account.refreshToken, + mailbox, top: 10, signal: controller.signal, }); @@ -876,10 +1248,12 @@ async function requestHotmailRemoteMailbox(account, mailbox = 'INBOX') { mailbox, payload: { source: 'microsoft-api', + transport: result.transport, + tokenStrategy: result.tokenStrategy, }, messages: normalizeHotmailMailApiMessages(result.messages).map((message) => ({ ...message, - mailbox: 'INBOX', + mailbox: message?.mailbox || mailbox, })), nextRefreshToken: result.nextRefreshToken, }; @@ -914,28 +1288,33 @@ function buildHotmailMailApiFailureAccount(account, errorMessage) { async function fetchHotmailMailboxMessagesFromRemoteService(account, mailboxes = HOTMAIL_MAILBOXES) { let workingAccount = normalizeHotmailAccount(account); - const requestedMailboxes = Array.isArray(mailboxes) && mailboxes.length ? mailboxes : ['INBOX']; + const mailboxResults = []; try { - const result = await requestHotmailRemoteMailbox(workingAccount, 'INBOX'); - workingAccount = applyHotmailApiResultToAccount(workingAccount, result); - const inboxMessages = result.messages.map((message) => ({ ...message, mailbox: 'INBOX' })); - const mailboxResults = requestedMailboxes.map((mailbox) => ({ - mailbox, - count: mailbox === 'INBOX' ? inboxMessages.length : 0, - messages: mailbox === 'INBOX' ? inboxMessages : [], - })); - const savedAccount = await upsertHotmailAccount(workingAccount); - return { - account: savedAccount, - mailboxResults, - messages: mailboxResults.flatMap((item) => item.messages), - }; + for (const mailbox of mailboxes) { + const result = await requestHotmailRemoteMailbox(workingAccount, mailbox); + workingAccount = applyHotmailApiResultToAccount(workingAccount, result); + mailboxResults.push({ + mailbox, + count: result.messages.length, + messages: result.messages.map((message) => ({ + ...message, + mailbox: message?.mailbox || mailbox, + })), + }); + } } catch (err) { const failedAccount = buildHotmailMailApiFailureAccount(workingAccount, err.message); await upsertHotmailAccount(failedAccount); throw err; } + + const savedAccount = await upsertHotmailAccount(workingAccount); + return { + account: savedAccount, + mailboxResults, + messages: mailboxResults.flatMap((item) => item.messages), + }; } async function requestHotmailLocalMessages(account, mailboxes = HOTMAIL_MAILBOXES) { @@ -1204,33 +1583,76 @@ async function pollHotmailVerificationCode(step, state, pollPayload = {}) { if (serviceSettings.mode === HOTMAIL_SERVICE_MODE_LOCAL) { return pollHotmailVerificationCodeViaLocalHelper(step, account, pollPayload); } - try { - await addLog(`步骤 ${step}:正在通过 API对接 轮询 Hotmail 验证码...`, 'info'); - const result = await fetchMicrosoftVerificationCode({ - token: account.refreshToken, - clientId: account.clientId, - filterAfterTimestamp: pollPayload.filterAfterTimestamp || 0, - maxRetries: Number(pollPayload.maxAttempts) || 5, - retryDelayMs: Number(pollPayload.intervalMs) || 3000, - log: (message) => { - void addLog(`步骤 ${step}:${message}`, 'info'); - }, - }); - account = await upsertHotmailAccount(applyHotmailApiResultToAccount(account, { - nextRefreshToken: result.nextRefreshToken, - })); - await addLog(`步骤 ${step}:已通过 API对接 找到验证码:${result.code}`, 'ok'); - return { - ok: true, - code: result.code, - emailTimestamp: result.emailTimestamp || Date.now(), - mailId: result.messageId || '', - }; - } catch (err) { - const failedAccount = buildHotmailMailApiFailureAccount(account, err.message); - await upsertHotmailAccount(failedAccount); - throw new Error(`步骤 ${step}:${err.message}`); + + 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 || '未知时间'; + const sender = message?.from?.emailAddress?.address || '未知发件人'; + const subject = message?.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}:正在通过 API对接 轮询 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}:已通过 API对接 在 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 API 对接轮询失败:${err.message}`, 'warn'); + } + + if (attempt < maxAttempts) { + await sleepWithStop(intervalMs); + } + } + + throw lastError || new Error(`步骤 ${step}:未在 Hotmail 收件箱中找到新的匹配验证码。`); } function generateRandomSuffix(length = 6) { @@ -1242,14 +1664,20 @@ function generateRandomSuffix(length = 6) { return suffix; } -function isGeneratedAliasProvider(provider) { - return provider === '2925'; +function isGeneratedAliasProvider(stateOrProvider, mail2925Mode = undefined) { + const provider = typeof stateOrProvider === 'string' + ? stateOrProvider + : stateOrProvider?.mailProvider; + const resolvedMail2925Mode = mail2925Mode !== undefined + ? normalizeMail2925Mode(mail2925Mode) + : getMail2925Mode(stateOrProvider); + return provider === '2925' && resolvedMail2925Mode === MAIL_2925_MODE_PROVIDE; } function shouldUseCustomRegistrationEmail(state = {}) { return isCustomMailProvider(state) || (!isHotmailProvider(state) - && !isGeneratedAliasProvider(state.mailProvider) + && !isGeneratedAliasProvider(state) && normalizeEmailGenerator(state.emailGenerator) === 'custom'); } @@ -1261,13 +1689,1226 @@ function buildGeneratedAliasEmail(state) { throw new Error('2925 邮箱前缀未设置,请先在侧边栏填写。'); } - if (provider === '2925') { + if (provider === '2925' && isGeneratedAliasProvider(state)) { return `${emailPrefix}${generateRandomSuffix(6)}@2925.com`; } throw new Error(`未支持的别名邮箱类型:${provider}`); } +function getLuckmailSessionConfig(state = {}) { + return { + apiKey: String(state.luckmailApiKey || ''), + baseUrl: normalizeLuckmailBaseUrl(state.luckmailBaseUrl), + emailType: normalizeLuckmailEmailType(state.luckmailEmailType), + domain: String(state.luckmailDomain || '').trim(), + }; +} + +function ensureLuckmailApiKey(state = {}) { + const apiKey = String(state.luckmailApiKey || '').trim(); + if (!apiKey) { + throw new Error('LuckMail API Key 为空,请先在侧边栏填写。'); + } + return apiKey; +} + +async function requestLuckmail(method, path, { baseUrl, apiKey, params, jsonData, timeout = 30000 } = {}) { + const requestUrl = new URL(`${normalizeLuckmailBaseUrl(baseUrl)}${path}`); + if (params && typeof params === 'object') { + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === '') continue; + requestUrl.searchParams.set(key, String(value)); + } + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + const headers = { + Accept: 'application/json', + }; + if (apiKey) { + headers['X-API-Key'] = apiKey; + } + + const upperMethod = String(method || 'GET').toUpperCase(); + const fetchOptions = { + method: upperMethod, + headers, + signal: controller.signal, + }; + if (jsonData !== undefined) { + headers['Content-Type'] = 'application/json'; + fetchOptions.body = JSON.stringify(jsonData || {}); + } + + let response = null; + try { + response = await fetch(requestUrl.toString(), fetchOptions); + } catch (err) { + if (err?.name === 'AbortError') { + throw new Error(`LuckMail 请求超时:${path}`); + } + throw new Error(`LuckMail 请求失败:${err.message}`); + } finally { + clearTimeout(timeoutId); + } + + let payload = null; + try { + payload = await response.json(); + } catch { + throw new Error(`LuckMail 返回了无法解析的响应:${path}`); + } + + if (!response.ok) { + const errorText = String(payload?.message || response.statusText || 'HTTP error'); + throw new Error(`LuckMail 请求失败:${errorText}`); + } + + if (!payload || typeof payload !== 'object') { + throw new Error(`LuckMail 返回数据无效:${path}`); + } + + if (payload.code !== 0) { + const errorText = String(payload.message || 'Unknown error'); + throw new Error(`LuckMail 接口返回失败:${errorText}`); + } + + return payload.data; +} + +function createLuckmailClient(state = {}) { + const config = getLuckmailSessionConfig(state); + const apiKey = ensureLuckmailApiKey(state); + const request = (method, path, options = {}) => requestLuckmail(method, path, { + baseUrl: config.baseUrl, + apiKey, + ...options, + }); + + return { + user: { + async purchaseEmails(projectCode, quantity, { emailType, domain } = {}) { + const body = { + project_code: projectCode, + quantity, + email_type: normalizeLuckmailEmailType(emailType), + }; + if (domain) { + body.domain = String(domain).trim(); + } + return request('POST', '/api/v1/openapi/email/purchase', { + jsonData: body, + }); + }, + async getPurchases({ page = 1, pageSize = 100, projectId, tagId, keyword, userDisabled } = {}) { + return normalizeLuckmailPurchaseListPage(await request('GET', '/api/v1/openapi/email/purchases', { + params: { + page, + page_size: pageSize, + project_id: projectId, + tag_id: tagId, + keyword, + user_disabled: userDisabled, + }, + })); + }, + async getTokenCode(token) { + return normalizeLuckmailTokenCode(await request( + 'GET', + `/api/v1/openapi/email/token/${encodeURIComponent(token)}/code` + )); + }, + async checkTokenAlive(token) { + const data = await request( + 'GET', + `/api/v1/openapi/email/token/${encodeURIComponent(token)}/alive` + ); + return { + email_address: String(data?.email_address || ''), + project: String(data?.project || ''), + alive: Boolean(data?.alive), + status: String(data?.status || ''), + message: String(data?.message || ''), + mail_count: Number(data?.mail_count) || 0, + }; + }, + async getTokenMails(token) { + const data = await request('GET', `/api/v1/openapi/email/token/${encodeURIComponent(token)}/mails`); + return { + email_address: String(data?.email_address || ''), + project: String(data?.project || ''), + warranty_until: String(data?.warranty_until || ''), + mails: normalizeLuckmailTokenMails(data?.mails || []), + }; + }, + async getTokenMailDetail(token, messageId) { + return normalizeLuckmailTokenMail(await request( + 'GET', + `/api/v1/openapi/email/token/${encodeURIComponent(token)}/mails/${encodeURIComponent(messageId)}` + )); + }, + async setPurchaseDisabled(purchaseId, disabled) { + await request('PUT', `/api/v1/openapi/email/purchases/${encodeURIComponent(purchaseId)}/disabled`, { + jsonData: { + disabled: disabled ? 1 : 0, + }, + }); + }, + async batchSetPurchaseDisabled(ids, disabled) { + await request('POST', '/api/v1/openapi/email/purchases/batch-disabled', { + jsonData: { + ids: (Array.isArray(ids) ? ids : []).map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0), + disabled: disabled ? 1 : 0, + }, + }); + }, + async setPurchaseTag(purchaseId, { tagId, tagName } = {}) { + const body = {}; + if (tagId !== undefined) { + body.tag_id = Number(tagId) || 0; + } + if (tagName !== undefined) { + body.tag_name = String(tagName || '').trim(); + } + await request('PUT', `/api/v1/openapi/email/purchases/${encodeURIComponent(purchaseId)}/tag`, { + jsonData: body, + }); + }, + async batchSetPurchaseTag(ids, { tagId, tagName } = {}) { + const body = { + ids: (Array.isArray(ids) ? ids : []).map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0), + }; + if (tagId !== undefined) { + body.tag_id = Number(tagId) || 0; + } + if (tagName !== undefined) { + body.tag_name = String(tagName || '').trim(); + } + await request('POST', '/api/v1/openapi/email/purchases/batch-tag', { + jsonData: body, + }); + }, + async getTags() { + return normalizeLuckmailTags(await request('GET', '/api/v1/openapi/email/tags')); + }, + async createTag(name, limitType, remark) { + const body = { + name: String(name || '').trim(), + limit_type: Number(limitType) || 0, + }; + if (remark !== undefined) { + body.remark = String(remark || '').trim(); + } + return normalizeLuckmailTags([await request('POST', '/api/v1/openapi/email/tags', { + jsonData: body, + })])[0] || null; + }, + }, + }; +} + +function getCurrentLuckmailPurchase(state = {}) { + return state.currentLuckmailPurchase + ? normalizeLuckmailPurchase(state.currentLuckmailPurchase) + : null; +} + +function buildLuckmailPurchaseView(purchase, state = {}) { + const normalizedPurchase = normalizeLuckmailPurchase(purchase); + const usedPurchases = getLuckmailUsedPurchases(state); + const preserveTagInfo = getLuckmailPreserveTagInfo(state); + + return { + id: normalizedPurchase.id, + email_address: normalizedPurchase.email_address, + project_name: normalizeLuckmailProjectName(normalizedPurchase.project_name) || DEFAULT_LUCKMAIL_PROJECT_CODE, + price: normalizedPurchase.price, + status: normalizedPurchase.status, + tag_id: normalizedPurchase.tag_id, + tag_name: normalizedPurchase.tag_name, + user_disabled: normalizedPurchase.user_disabled, + warranty_hours: normalizedPurchase.warranty_hours, + warranty_until: normalizedPurchase.warranty_until, + created_at: normalizedPurchase.created_at, + used: Boolean(usedPurchases[normalizeLuckmailPurchaseId(normalizedPurchase.id)]), + preserved: isLuckmailPurchasePreserved(normalizedPurchase, { + preserveTagId: preserveTagInfo.id, + preserveTagName: preserveTagInfo.name, + }), + disabled: normalizedPurchase.user_disabled === 1, + current: Number(getCurrentLuckmailPurchase(state)?.id) === normalizedPurchase.id, + reusable: isLuckmailPurchaseReusable(normalizedPurchase, { + projectCode: DEFAULT_LUCKMAIL_PROJECT_CODE, + usedPurchases, + preserveTagId: preserveTagInfo.id, + preserveTagName: preserveTagInfo.name, + now: Date.now(), + }), + }; +} + +async function getAllLuckmailPurchases(state, options = {}) { + const client = options.client || createLuckmailClient(state); + const pageSize = Math.max(1, Math.min(100, Number(options.pageSize) || 100)); + const maxPages = Math.max(1, Number(options.maxPages) || 50); + const purchases = []; + + for (let page = 1; page <= maxPages; page += 1) { + const pageResult = await client.user.getPurchases({ + page, + pageSize, + keyword: options.keyword, + projectId: options.projectId, + tagId: options.tagId, + userDisabled: options.userDisabled, + }); + const normalizedPage = normalizeLuckmailPurchaseListPage(pageResult); + purchases.push(...normalizedPage.list); + + if (normalizedPage.list.length === 0) { + break; + } + if (normalizedPage.total > 0 && purchases.length >= normalizedPage.total) { + break; + } + if (normalizedPage.list.length < normalizedPage.page_size) { + break; + } + } + + return purchases; +} + +async function listLuckmailPurchasesByProject(state, options = {}) { + const projectCode = normalizeLuckmailProjectName(options.projectCode || DEFAULT_LUCKMAIL_PROJECT_CODE) + || DEFAULT_LUCKMAIL_PROJECT_CODE; + const purchases = await getAllLuckmailPurchases(state, options); + return purchases.filter((purchase) => isLuckmailPurchaseForProject(purchase, projectCode)); +} + +async function getLuckmailPurchaseById(state, purchaseId, options = {}) { + const normalizedPurchaseId = Number(normalizeLuckmailPurchaseId(purchaseId)) || 0; + if (!normalizedPurchaseId) { + throw new Error('LuckMail 邮箱 ID 无效。'); + } + + const purchases = await listLuckmailPurchasesByProject(state, options); + const purchase = purchases.find((item) => item.id === normalizedPurchaseId) || null; + if (!purchase) { + throw new Error(`未找到 ID=${normalizedPurchaseId} 的 openai LuckMail 邮箱。`); + } + return purchase; +} + +async function listLuckmailPurchasesForManagement() { + const state = await getState(); + const purchases = await listLuckmailPurchasesByProject(state, { + projectCode: DEFAULT_LUCKMAIL_PROJECT_CODE, + }); + return purchases.map((purchase) => buildLuckmailPurchaseView(purchase, state)); +} + +async function ensureLuckmailPreserveTag(client, state = null) { + const resolvedState = state || await getState(); + const preserveTagInfo = getLuckmailPreserveTagInfo(resolvedState); + if (preserveTagInfo.id > 0) { + return preserveTagInfo; + } + + const tags = normalizeLuckmailTags(await client.user.getTags()); + let preserveTag = tags.find( + (tag) => normalizeLuckmailProjectName(tag.name) === normalizeLuckmailProjectName(preserveTagInfo.name) + ) || null; + + if (!preserveTag) { + preserveTag = await client.user.createTag( + DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME, + 0, + '保留邮箱(不参与自动复用)' + ); + } + + await setLuckmailPreserveTagInfo(preserveTag); + return { + id: Number(preserveTag?.id) || 0, + name: String(preserveTag?.name || '').trim() || DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME, + }; +} + +async function activateLuckmailPurchaseForFlow(state, client, purchase, options = {}) { + const normalizedPurchase = normalizeLuckmailPurchase(purchase); + if (!normalizedPurchase?.email_address || !normalizedPurchase?.token) { + throw new Error('LuckMail 邮箱缺少 email/token,无法用于当前流程。'); + } + + let baselineCursor = null; + if (options.initializeCursor !== false) { + const mailList = await client.user.getTokenMails(normalizedPurchase.token); + baselineCursor = buildLuckmailBaselineCursor(mailList?.mails || []); + } + + await setLuckmailPurchaseState(normalizedPurchase); + await setLuckmailMailCursorState(baselineCursor); + await setEmailState(normalizedPurchase.email_address); + + if (options.logMessage) { + await addLog(options.logMessage, options.logLevel || 'ok'); + } + + return normalizedPurchase; +} + +async function findReusableLuckmailPurchaseForFlow(state, client) { + const preserveTagInfo = getLuckmailPreserveTagInfo(state); + const reusablePurchases = filterReusableLuckmailPurchases( + await listLuckmailPurchasesByProject(state, { + client, + projectCode: DEFAULT_LUCKMAIL_PROJECT_CODE, + }), + { + projectCode: DEFAULT_LUCKMAIL_PROJECT_CODE, + usedPurchases: getLuckmailUsedPurchases(state), + preserveTagId: preserveTagInfo.id, + preserveTagName: preserveTagInfo.name, + now: Date.now(), + } + ); + + for (const candidate of reusablePurchases) { + try { + const aliveResult = await client.user.checkTokenAlive(candidate.token); + if (!aliveResult?.alive) { + await addLog( + `LuckMail:跳过不可复用邮箱 ${candidate.email_address}:${aliveResult?.message || aliveResult?.status || 'token 不可用'}`, + 'warn' + ); + continue; + } + return candidate; + } catch (err) { + await addLog(`LuckMail:检测复用邮箱 ${candidate.email_address} 失败:${err.message}`, 'warn'); + } + } + + return null; +} + +async function selectLuckmailPurchase(purchaseId) { + const state = await ensureManualInteractionAllowed('切换 LuckMail 邮箱'); + const client = createLuckmailClient(state); + const purchase = await getLuckmailPurchaseById(state, purchaseId, { + client, + projectCode: DEFAULT_LUCKMAIL_PROJECT_CODE, + }); + + if (purchase.user_disabled === 1) { + throw new Error(`LuckMail 邮箱 ${purchase.email_address} 已禁用,无法使用。`); + } + + const aliveResult = await client.user.checkTokenAlive(purchase.token); + if (!aliveResult?.alive) { + throw new Error(`LuckMail 邮箱 ${purchase.email_address} 当前不可用:${aliveResult?.message || aliveResult?.status || 'token 已失效'}`); + } + + const activatedPurchase = await activateLuckmailPurchaseForFlow(state, client, purchase, { + initializeCursor: true, + logMessage: `LuckMail:已切换当前邮箱为 ${purchase.email_address}`, + }); + const nextState = await getState(); + return buildLuckmailPurchaseView(activatedPurchase, nextState); +} + +async function setLuckmailPurchasePreservedState(purchaseId, preserved) { + const state = await ensureManualInteractionAllowed('设置 LuckMail 邮箱保留状态'); + const client = createLuckmailClient(state); + const purchase = await getLuckmailPurchaseById(state, purchaseId, { + client, + projectCode: DEFAULT_LUCKMAIL_PROJECT_CODE, + }); + + if (preserved) { + const preserveTag = await ensureLuckmailPreserveTag(client, state); + await client.user.setPurchaseTag(purchase.id, { tagId: preserveTag.id }); + } else { + await client.user.setPurchaseTag(purchase.id, { tagId: 0 }); + } + + await addLog(`LuckMail:已将 ${purchase.email_address} ${preserved ? '设为保留' : '取消保留'}`, 'ok'); + const refreshedState = await getState(); + const refreshedPurchase = await getLuckmailPurchaseById(refreshedState, purchase.id, { + client, + projectCode: DEFAULT_LUCKMAIL_PROJECT_CODE, + }); + return buildLuckmailPurchaseView(refreshedPurchase, await getState()); +} + +async function setLuckmailPurchaseDisabledState(purchaseId, disabled) { + const state = await ensureManualInteractionAllowed(disabled ? '禁用 LuckMail 邮箱' : '启用 LuckMail 邮箱'); + const client = createLuckmailClient(state); + const purchase = await getLuckmailPurchaseById(state, purchaseId, { + client, + projectCode: DEFAULT_LUCKMAIL_PROJECT_CODE, + }); + + await client.user.setPurchaseDisabled(purchase.id, disabled ? 1 : 0); + + const currentPurchase = getCurrentLuckmailPurchase(await getState()); + if (disabled && currentPurchase?.id === purchase.id) { + await clearLuckmailRuntimeState({ clearEmail: isLuckmailProvider(await getState()) }); + } + + await addLog(`LuckMail:已将 ${purchase.email_address} ${disabled ? '禁用' : '启用'}`, 'ok'); + const refreshedState = await getState(); + const refreshedPurchase = await getLuckmailPurchaseById(refreshedState, purchase.id, { + client, + projectCode: DEFAULT_LUCKMAIL_PROJECT_CODE, + }); + return buildLuckmailPurchaseView(refreshedPurchase, await getState()); +} + +async function batchUpdateLuckmailPurchases(input = {}) { + const action = String(input.action || '').trim(); + const selectedIds = Array.isArray(input.ids) + ? [...new Set(input.ids.map((id) => Number(normalizeLuckmailPurchaseId(id)) || 0).filter((id) => id > 0))] + : []; + if (!selectedIds.length) { + throw new Error('请先选择至少一个 LuckMail 邮箱。'); + } + + const state = await ensureManualInteractionAllowed('批量更新 LuckMail 邮箱'); + const client = createLuckmailClient(state); + const purchases = await listLuckmailPurchasesByProject(state, { + client, + projectCode: DEFAULT_LUCKMAIL_PROJECT_CODE, + }); + const purchaseMap = new Map(purchases.map((purchase) => [purchase.id, purchase])); + const targetPurchases = selectedIds.map((id) => purchaseMap.get(id)).filter(Boolean); + + if (!targetPurchases.length) { + throw new Error('未找到可批量处理的 openai LuckMail 邮箱。'); + } + + const targetIds = targetPurchases.map((purchase) => purchase.id); + + if (action === 'used' || action === 'unused') { + const nextUsedState = getLuckmailUsedPurchases(state); + targetIds.forEach((id) => { + const key = normalizeLuckmailPurchaseId(id); + if (!key) return; + if (action === 'used') { + nextUsedState[key] = true; + } else { + delete nextUsedState[key]; + } + }); + await setLuckmailUsedPurchasesState(nextUsedState); + await addLog(`LuckMail:已批量${action === 'used' ? '标记已用' : '标记未用'} ${targetIds.length} 个邮箱`, 'ok'); + } else if (action === 'preserve' || action === 'unpreserve') { + if (action === 'preserve') { + const preserveTag = await ensureLuckmailPreserveTag(client, state); + await client.user.batchSetPurchaseTag(targetIds, { tagId: preserveTag.id }); + } else { + await client.user.batchSetPurchaseTag(targetIds, { tagId: 0 }); + } + await addLog(`LuckMail:已批量${action === 'preserve' ? '保留' : '取消保留'} ${targetIds.length} 个邮箱`, 'ok'); + } else if (action === 'disable' || action === 'enable') { + await client.user.batchSetPurchaseDisabled(targetIds, action === 'disable' ? 1 : 0); + const currentPurchase = getCurrentLuckmailPurchase(await getState()); + if (action === 'disable' && currentPurchase?.id && targetIds.includes(currentPurchase.id)) { + await clearLuckmailRuntimeState({ clearEmail: isLuckmailProvider(await getState()) }); + } + await addLog(`LuckMail:已批量${action === 'disable' ? '禁用' : '启用'} ${targetIds.length} 个邮箱`, 'ok'); + } else { + throw new Error(`不支持的 LuckMail 批量操作:${action}`); + } + + return { + updatedIds: targetIds, + }; +} + +async function disableUsedLuckmailPurchases() { + const state = await ensureManualInteractionAllowed('禁用已用 LuckMail 邮箱'); + const usedPurchases = getLuckmailUsedPurchases(state); + const preserveTagInfo = getLuckmailPreserveTagInfo(state); + const client = createLuckmailClient(state); + const purchases = await listLuckmailPurchasesByProject(state, { + client, + projectCode: DEFAULT_LUCKMAIL_PROJECT_CODE, + }); + const targets = purchases.filter((purchase) => { + const purchaseId = normalizeLuckmailPurchaseId(purchase.id); + return Boolean(purchaseId && usedPurchases[purchaseId]) + && !isLuckmailPurchasePreserved(purchase, { + preserveTagId: preserveTagInfo.id, + preserveTagName: preserveTagInfo.name, + }) + && purchase.user_disabled !== 1; + }); + + if (!targets.length) { + return { disabledIds: [] }; + } + + const targetIds = targets.map((purchase) => purchase.id); + await client.user.batchSetPurchaseDisabled(targetIds, 1); + const currentPurchase = getCurrentLuckmailPurchase(await getState()); + if (currentPurchase?.id && targetIds.includes(currentPurchase.id)) { + await clearLuckmailRuntimeState({ clearEmail: isLuckmailProvider(await getState()) }); + } + await addLog(`LuckMail:已禁用 ${targetIds.length} 个本地已用邮箱`, 'ok'); + return { disabledIds: targetIds }; +} + +async function ensureLuckmailPurchaseForFlow(options = {}) { + const { allowReuse = true } = options; + const state = await getState(); + const existingPurchase = getCurrentLuckmailPurchase(state); + if (allowReuse && existingPurchase?.email_address && existingPurchase?.token) { + if (state.email !== existingPurchase.email_address) { + await setEmailState(existingPurchase.email_address); + } + return existingPurchase; + } + + const config = getLuckmailSessionConfig(state); + const client = createLuckmailClient(state); + if (allowReuse) { + const reusablePurchase = await findReusableLuckmailPurchaseForFlow(state, client); + if (reusablePurchase) { + return activateLuckmailPurchaseForFlow(state, client, reusablePurchase, { + initializeCursor: true, + logMessage: `LuckMail:已复用 openai 邮箱 ${reusablePurchase.email_address}`, + }); + } + } + + const result = await client.user.purchaseEmails(DEFAULT_LUCKMAIL_PROJECT_CODE, 1, { + emailType: config.emailType, + domain: config.domain || undefined, + }); + const purchases = normalizeLuckmailPurchases(result); + const purchase = purchases[0] || null; + if (!purchase?.email_address || !purchase?.token) { + throw new Error('LuckMail 购邮成功,但未返回可用邮箱或 token。'); + } + + return activateLuckmailPurchaseForFlow(state, client, purchase, { + initializeCursor: false, + logMessage: `LuckMail:已购买邮箱 ${purchase.email_address}(类型:${config.emailType},项目:${DEFAULT_LUCKMAIL_PROJECT_CODE})`, + }); +} + +async function resolveLuckmailVerificationMail(client, token, filters = {}, tokenCodeResult = null) { + const tokenCode = tokenCodeResult ? normalizeLuckmailTokenCode(tokenCodeResult) : null; + if (tokenCode?.mail) { + const tokenMail = tokenCode.verification_code && !tokenCode.mail.verification_code + ? { + ...tokenCode.mail, + verification_code: tokenCode.verification_code, + } + : tokenCode.mail; + const inlineMatch = pickLuckmailVerificationMail([tokenMail], filters); + if (inlineMatch) { + return inlineMatch; + } + } + + const mailList = await client.user.getTokenMails(token); + let match = pickLuckmailVerificationMail(mailList.mails, filters); + if (match?.mail?.message_id && !match.mail.verification_code) { + const detail = await client.user.getTokenMailDetail(token, match.mail.message_id); + match = pickLuckmailVerificationMail([detail], filters); + } + return match || null; +} + +async function pollLuckmailVerificationCode(step, state, pollPayload = {}) { + const purchase = getCurrentLuckmailPurchase(state); + if (!purchase?.token) { + throw new Error('LuckMail 当前没有可用 token,请先执行步骤 3 购买邮箱。'); + } + + const client = createLuckmailClient(state); + const maxAttempts = Math.max(1, Number(pollPayload.maxAttempts) || 5); + const intervalMs = Math.max(1000, Number(pollPayload.intervalMs) || 3000); + const filters = { + afterTimestamp: pollPayload.filterAfterTimestamp || 0, + senderFilters: pollPayload.senderFilters || [], + subjectFilters: pollPayload.subjectFilters || [], + excludeCodes: pollPayload.excludeCodes || [], + }; + + let lastError = null; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + throwIfStopped(); + await addLog(`步骤 ${step}:正在通过 LuckMail 轮询验证码(${attempt}/${maxAttempts})...`, 'info'); + + try { + const tokenCode = await client.user.getTokenCode(purchase.token); + const cursor = normalizeLuckmailMailCursor((await getState()).currentLuckmailMailCursor); + if (tokenCode.verification_code && tokenCode.mail && !isLuckmailMailNewerThanCursor(tokenCode.mail, cursor)) { + throw new Error(`步骤 ${step}:LuckMail 返回的最新邮件仍是旧验证码。`); + } + + let match = null; + if (tokenCode.has_new_mail || tokenCode.verification_code) { + match = await resolveLuckmailVerificationMail(client, purchase.token, filters, tokenCode); + } + if (!match) { + match = await resolveLuckmailVerificationMail(client, purchase.token, filters, null); + } + + if (match?.mail) { + const cursor = normalizeLuckmailMailCursor((await getState()).currentLuckmailMailCursor); + if (!isLuckmailMailNewerThanCursor(match.mail, cursor)) { + throw new Error(`步骤 ${step}:LuckMail 命中的邮件不是新邮件。`); + } + + await setLuckmailMailCursorState(buildLuckmailMailCursor(match.mail)); + return { + ok: true, + code: match.code, + emailTimestamp: normalizeLuckmailTimestamp(match.mail.received_at) || Date.now(), + mailId: match.mail.message_id, + }; + } + + lastError = new Error(`步骤 ${step}:暂未在 LuckMail 邮箱中找到新的匹配验证码。`); + } catch (err) { + if (isStopError(err)) { + throw err; + } + lastError = err; + await addLog(`步骤 ${step}:LuckMail 轮询失败:${err.message}`, 'warn'); + } + + if (attempt < maxAttempts) { + await sleepWithStop(intervalMs); + } + } + + throw lastError || new Error(`步骤 ${step}:未在 LuckMail 邮箱中找到新的匹配验证码。`); +} + +function summarizeCloudflareTempEmailMessagesForLog(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 || '未知时间'; + const sender = message?.from?.emailAddress?.address || '未知发件人'; + const subject = message?.subject || '(无主题)'; + const preview = String(message?.bodyPreview || '').replace(/\s+/g, ' ').trim().slice(0, 80); + const address = message?.address || '未知地址'; + return `[${address}] ${receivedAt} | ${sender} | ${subject} | ${preview}`; + }) + .join(' || '); +} + +async function deleteCloudflareTempEmailMail(config, mailId) { + const normalizedMailId = String(mailId || '').trim(); + if (!normalizedMailId) return false; + + await requestCloudflareTempEmailJson(config, `/admin/mails/${encodeURIComponent(normalizedMailId)}`, { + method: 'DELETE', + }); + return true; +} + +async function listCloudflareTempEmailMessages(state, options = {}) { + const config = ensureCloudflareTempEmailConfig(state, { requireAdminAuth: true }); + const address = normalizeCloudflareTempEmailAddress(options.address); + const payload = await requestCloudflareTempEmailJson(config, '/admin/mails', { + method: 'GET', + searchParams: { + limit: Number(options.limit) || CLOUDFLARE_TEMP_EMAIL_DEFAULT_PAGE_SIZE, + offset: Number(options.offset) || 0, + address, + }, + }); + + const messages = normalizeCloudflareTempEmailMailApiMessages(payload).filter((message) => { + if (!address) return true; + return !message.address || normalizeCloudflareTempEmailAddress(message.address) === address; + }); + + return { config, messages }; +} + +async function pollCloudflareTempEmailVerificationCode(step, state, pollPayload = {}) { + const targetEmail = normalizeCloudflareTempEmailAddress(pollPayload.targetEmail || state.email); + if (!targetEmail) { + throw new Error('Cloudflare Temp Email 轮询前缺少目标邮箱地址。'); + } + + await addLog(`步骤 ${step}:正在轮询 Cloudflare Temp Email 邮件(${targetEmail})...`, 'info'); + const maxAttempts = Number(pollPayload.maxAttempts) || 5; + const intervalMs = Number(pollPayload.intervalMs) || 3000; + let lastError = null; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + throwIfStopped(); + try { + const { config, messages } = await listCloudflareTempEmailMessages(state, { + address: targetEmail, + limit: pollPayload.limit || CLOUDFLARE_TEMP_EMAIL_DEFAULT_PAGE_SIZE, + offset: pollPayload.offset || 0, + }); + const matchResult = pickVerificationMessageWithTimeFallback(messages, { + afterTimestamp: pollPayload.filterAfterTimestamp || 0, + senderFilters: pollPayload.senderFilters || [], + subjectFilters: pollPayload.subjectFilters || [], + excludeCodes: pollPayload.excludeCodes || [], + }); + const match = matchResult.match; + + if (match?.code) { + if (matchResult.usedRelaxedFilters) { + const fallbackLabel = matchResult.usedTimeFallback ? '宽松匹配 + 时间回退' : '宽松匹配'; + await addLog(`步骤 ${step}:严格规则未命中,已改用 ${fallbackLabel} 并命中 Cloudflare Temp Email 验证码。`, 'warn'); + } + try { + await deleteCloudflareTempEmailMail(config, match.message?.id); + } catch (err) { + await addLog(`步骤 ${step}:删除 Cloudflare Temp Email 邮件失败:${err.message}`, 'warn'); + } + return { + ok: true, + code: match.code, + emailTimestamp: match.receivedAt || Date.now(), + mailId: match.message?.id || '', + }; + } + + lastError = new Error(`步骤 ${step}:暂未在 Cloudflare Temp Email 中找到匹配验证码(${attempt}/${maxAttempts})。`); + await addLog(lastError.message, attempt === maxAttempts ? 'warn' : 'info'); + const sample = summarizeCloudflareTempEmailMessagesForLog(messages); + if (sample) { + await addLog(`步骤 ${step}:最近邮件样本:${sample}`, 'info'); + } + } catch (err) { + lastError = err; + await addLog(`步骤 ${step}:Cloudflare Temp Email 轮询失败:${err.message}`, 'warn'); + } + + if (attempt < maxAttempts) { + await sleepWithStop(intervalMs); + } + } + + throw lastError || new Error(`步骤 ${step}:未在 Cloudflare Temp Email 中找到新的匹配验证码。`); +} + +async function getOpenIcloudHostPreference() { + try { + const tabs = await chrome.tabs.query({ + url: [ + 'https://www.icloud.com/*', + 'https://www.icloud.com.cn/*', + ], + }); + + const activeTab = tabs.find((tab) => tab.active); + const candidates = activeTab ? [activeTab, ...tabs.filter((tab) => tab.id !== activeTab.id)] : tabs; + for (const tab of candidates) { + try { + const host = normalizeIcloudHost(new URL(tab.url).host); + if (host) return host; + } catch {} + } + } catch {} + + return ''; +} + +async function getPreferredIcloudLoginUrl(error = null, state = null) { + const currentState = state || await getState(); + const configuredHost = getConfiguredIcloudHostPreference(currentState); + if (configuredHost) { + return getIcloudLoginUrlForHost(configuredHost); + } + + const messageHint = getIcloudHostHintFromMessage(getErrorMessage(error)); + if (messageHint) { + return getIcloudLoginUrlForHost(messageHint); + } + + const savedHost = normalizeIcloudHost(currentState?.preferredIcloudHost); + if (savedHost) { + return getIcloudLoginUrlForHost(savedHost); + } + + const openHost = await getOpenIcloudHostPreference(); + if (openHost) { + return getIcloudLoginUrlForHost(openHost); + } + + return ICLOUD_LOGIN_URLS[0]; +} + +async function getPreferredIcloudSetupUrls(state = null, error = null) { + const preferredLoginUrl = await getPreferredIcloudLoginUrl(error, state); + const preferredHost = normalizeIcloudHost(new URL(preferredLoginUrl).host); + const preferredSetupUrl = getIcloudSetupUrlForHost(preferredHost); + if (!preferredSetupUrl) { + return [...ICLOUD_SETUP_URLS]; + } + return [ + preferredSetupUrl, + ...ICLOUD_SETUP_URLS.filter((url) => url !== preferredSetupUrl), + ]; +} + +function isIcloudLoginRequiredError(error) { + const message = getErrorMessage(error).toLowerCase(); + return message.includes('could not validate icloud session') + || message.includes('hide my email service was unavailable') + || /\bstatus (401|403|409|421)\b/.test(message); +} + +let lastIcloudLoginPromptAt = 0; + +async function openIcloudLoginPage(preferredUrl) { + const tabs = await chrome.tabs.query({ + url: [ + 'https://www.icloud.com/*', + 'https://www.icloud.com.cn/*', + ], + }); + const preferredHost = new URL(preferredUrl).host; + const existing = tabs.find((tab) => { + try { + return new URL(tab.url).host === preferredHost; + } catch { + return false; + } + }); + + if (existing?.id) { + await chrome.tabs.update(existing.id, { active: true }); + if (existing.url !== preferredUrl) { + await chrome.tabs.update(existing.id, { url: preferredUrl }); + } + return existing.id; + } + + const created = await chrome.tabs.create({ url: preferredUrl, active: true }); + return created.id; +} + +async function promptIcloudLogin(error, actionLabel = 'iCloud 操作') { + const now = Date.now(); + const preferredUrl = await getPreferredIcloudLoginUrl(error); + const originalError = getErrorMessage(error); + + chrome.runtime.sendMessage({ + type: 'ICLOUD_LOGIN_REQUIRED', + payload: { + actionLabel, + loginUrl: preferredUrl, + message: '需要先登录 iCloud,我已经为你打开登录页。', + detail: originalError, + }, + }).catch(() => { }); + + if (now - lastIcloudLoginPromptAt < 15000) { + return; + } + lastIcloudLoginPromptAt = now; + + await addLog(`iCloud:${actionLabel}时需要登录,正在打开 ${new URL(preferredUrl).host} ...`, 'warn'); + + try { + await openIcloudLoginPage(preferredUrl); + } catch (tabErr) { + await addLog(`iCloud:自动打开登录页失败:${getErrorMessage(tabErr)}`, 'warn'); + } +} + +async function withIcloudLoginHelp(actionLabel, action) { + try { + return await action(); + } catch (err) { + if (isIcloudLoginRequiredError(err)) { + await promptIcloudLogin(err, actionLabel); + throw new Error('请先在新打开的 iCloud 页面中完成登录,再回来点击“我已登录”。'); + } + throw err; + } +} + +async function icloudRequest(method, url, options = {}) { + const { data } = options; + let response; + try { + response = await fetch(url, { + method, + credentials: 'include', + headers: data !== undefined ? { 'Content-Type': 'application/json' } : undefined, + body: data !== undefined ? JSON.stringify(data) : undefined, + }); + } catch (err) { + throw new Error(`iCloud 请求失败:${method} ${url},${err.message}`); + } + + if (!response.ok) { + throw new Error(`iCloud 请求失败:${method} ${url},status ${response.status}`); + } + + try { + return await response.json(); + } catch (err) { + throw new Error(`iCloud 返回的 JSON 无法解析:${method} ${url},${err.message}`); + } +} + +async function validateIcloudSession(setupUrl) { + const data = await icloudRequest('POST', `${setupUrl}/validate`); + if (!data?.webservices?.premiummailsettings?.url) { + throw new Error('Could not validate iCloud session. Hide My Email service was unavailable.'); + } + return data; +} + +async function resolveIcloudPremiumMailService() { + const errors = []; + const state = await getState(); + const setupUrls = await getPreferredIcloudSetupUrls(state); + + for (const setupUrl of setupUrls) { + try { + const data = await validateIcloudSession(setupUrl); + const preferredIcloudHost = normalizeIcloudHost(new URL(setupUrl).host); + if (preferredIcloudHost && preferredIcloudHost !== normalizeIcloudHost(state.preferredIcloudHost)) { + await setState({ preferredIcloudHost }); + } + return { + setupUrl, + serviceUrl: String(data.webservices.premiummailsettings.url || '').replace(/\/$/, ''), + }; + } catch (err) { + errors.push(`${new URL(setupUrl).host}: ${getErrorMessage(err)}`); + } + } + + throw new Error(errors.length + ? `Could not validate iCloud session. ${errors.join(' | ')}` + : 'Could not validate iCloud session. 请先在当前浏览器登录 icloud.com.cn 或 icloud.com。'); +} + +function getIcloudAliasLabel() { + const now = new Date(); + const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; + return `MultiPage ${dateStr}`; +} + +async function checkIcloudSession() { + return withIcloudLoginHelp('检查 iCloud 会话', async () => { + const { setupUrl } = await resolveIcloudPremiumMailService(); + await addLog(`iCloud:会话校验通过(${new URL(setupUrl).host})`, 'ok'); + return { ok: true, setupUrl }; + }); +} + +async function listIcloudAliases() { + return withIcloudLoginHelp('加载 iCloud 隐私邮箱列表', async () => { + const { serviceUrl } = await resolveIcloudPremiumMailService(); + const response = await icloudRequest('GET', `${serviceUrl}/v2/hme/list`); + const state = await getState(); + return normalizeIcloudAliasList(response, { + usedEmails: getEffectiveUsedEmails(state), + preservedEmails: getPreservedAliasMap(state), + }); + }); +} + +async function deleteIcloudAlias(payload) { + return withIcloudLoginHelp('删除 iCloud 隐私邮箱', async () => { + const alias = typeof payload === 'string' + ? { email: String(payload).trim().toLowerCase(), anonymousId: '' } + : { + email: String(payload?.email || '').trim().toLowerCase(), + anonymousId: String(payload?.anonymousId || '').trim(), + }; + + if (!alias.email) { + throw new Error('未提供需要删除的 iCloud 隐私邮箱。'); + } + if (!alias.anonymousId) { + throw new Error(`缺少 ${alias.email} 的 anonymousId,请先刷新 iCloud 别名列表。`); + } + + const { serviceUrl } = await resolveIcloudPremiumMailService(); + + try { + const directDelete = await icloudRequest('POST', `${serviceUrl}/v1/hme/delete`, { + data: { anonymousId: alias.anonymousId }, + }); + if (directDelete?.success === false) { + throw new Error(directDelete?.error?.errorMessage || 'delete failed'); + } + } catch (err) { + await addLog(`iCloud:直接删除 ${alias.email} 失败,尝试先停用再删除...`, 'warn'); + + const deactivated = await icloudRequest('POST', `${serviceUrl}/v1/hme/deactivate`, { + data: { anonymousId: alias.anonymousId }, + }); + if (deactivated?.success === false) { + throw new Error(deactivated?.error?.errorMessage || `停用 ${alias.email} 失败`); + } + + const deleted = await icloudRequest('POST', `${serviceUrl}/v1/hme/delete`, { + data: { anonymousId: alias.anonymousId }, + }); + if (deleted?.success === false) { + throw new Error(deleted?.error?.errorMessage || `删除 ${alias.email} 失败`); + } + } + + const state = await getState(); + const manualAliasUsage = getManualAliasUsageMap(state); + const preservedAliases = getPreservedAliasMap(state); + delete manualAliasUsage[alias.email]; + delete preservedAliases[alias.email]; + await setState({ manualAliasUsage, preservedAliases }); + + await addLog(`iCloud:已删除 ${alias.email}`, 'ok'); + broadcastIcloudAliasesChanged({ reason: 'deleted', email: alias.email }); + return { email: alias.email }; + }); +} + +async function deleteUsedIcloudAliases() { + const aliases = await listIcloudAliases(); + const usedAliases = aliases.filter((alias) => alias.used); + if (!usedAliases.length) { + return { deleted: [], skipped: [] }; + } + + const deleted = []; + const skipped = []; + for (const alias of usedAliases) { + if (alias.preserved) { + skipped.push({ email: alias.email, error: 'preserved' }); + continue; + } + try { + await deleteIcloudAlias(alias); + deleted.push(alias.email); + } catch (err) { + skipped.push({ email: alias.email, error: getErrorMessage(err) }); + } + } + return { deleted, skipped }; +} + +async function fetchIcloudHideMyEmail() { + return withIcloudLoginHelp('获取 iCloud 隐私邮箱', async () => { + throwIfStopped(); + await addLog('iCloud:正在校验当前浏览器登录状态...', 'info'); + + const { serviceUrl, setupUrl } = await resolveIcloudPremiumMailService(); + await addLog(`iCloud:已通过 ${new URL(setupUrl).host} 验证会话`, 'ok'); + + const existingAliasesResponse = await icloudRequest('GET', `${serviceUrl}/v2/hme/list`); + const state = await getState(); + const existingAliases = normalizeIcloudAliasList(existingAliasesResponse, { + usedEmails: getEffectiveUsedEmails(state), + preservedEmails: getPreservedAliasMap(state), + }); + + const reusableAlias = pickReusableIcloudAlias(existingAliases); + if (reusableAlias) { + await setEmailState(reusableAlias.email); + await addLog(`iCloud:复用未使用别名 ${reusableAlias.email}`, 'ok'); + broadcastIcloudAliasesChanged({ reason: 'selected', email: reusableAlias.email }); + return reusableAlias.email; + } + + await addLog('iCloud:没有可复用别名,开始生成新的 Hide My Email 地址...', 'warn'); + + const generated = await icloudRequest('POST', `${serviceUrl}/v1/hme/generate`); + if (!generated?.success || !generated?.result?.hme) { + throw new Error(generated?.error?.errorMessage || 'iCloud 隐私邮箱生成失败。'); + } + + const reserved = await icloudRequest('POST', `${serviceUrl}/v1/hme/reserve`, { + data: { + hme: generated.result.hme, + label: getIcloudAliasLabel(), + note: 'Generated through Multi-Page Automation', + }, + }); + + if (!reserved?.success || !reserved?.result?.hme?.hme) { + throw new Error(reserved?.error?.errorMessage || 'iCloud 隐私邮箱保留失败。'); + } + + const alias = String(reserved.result.hme.hme || '').trim().toLowerCase(); + await setEmailState(alias); + await addLog(`iCloud:已创建并保留新别名 ${alias}`, 'ok'); + broadcastIcloudAliasesChanged({ reason: 'created', email: alias }); + return alias; + }); +} + +async function finalizeIcloudAliasAfterSuccessfulFlow(state) { + const email = String(state?.email || '').trim().toLowerCase(); + if (!email) { + return { handled: false, deleted: false }; + } + + const knownIcloudAlias = normalizeEmailGenerator(state?.emailGenerator) === 'icloud' + || Object.prototype.hasOwnProperty.call(getManualAliasUsageMap(state), email) + || Object.prototype.hasOwnProperty.call(getPreservedAliasMap(state), email); + if (!knownIcloudAlias) { + return { handled: false, deleted: false }; + } + + await setIcloudAliasUsedState({ email, used: true }, { silentLog: true }); + await addLog(`iCloud:流程成功后已标记 ${email} 为已用。`, 'ok'); + + if (!state.autoDeleteUsedIcloudAlias) { + return { handled: true, deleted: false }; + } + + if (isAliasPreserved(state, email)) { + await addLog(`iCloud:${email} 已被标记为保留,跳过自动删除。`, 'info'); + return { handled: true, deleted: false }; + } + + try { + const aliases = await listIcloudAliases(); + const alias = findIcloudAliasByEmail(aliases, email); + if (!alias) { + await addLog(`iCloud:自动删除跳过,列表中未找到 ${email}。`, 'warn'); + return { handled: true, deleted: false }; + } + if (alias.preserved) { + await addLog(`iCloud:${email} 在最新别名列表中已是保留状态,跳过自动删除。`, 'info'); + return { handled: true, deleted: false }; + } + if (!alias.anonymousId) { + await addLog(`iCloud:自动删除跳过,${email} 缺少 anonymousId,请先刷新列表后重试。`, 'warn'); + return { handled: true, deleted: false }; + } + await deleteIcloudAlias(alias); + await addLog(`iCloud:流程成功后已自动删除 ${email}。`, 'ok'); + return { handled: true, deleted: true }; + } catch (err) { + await addLog(`iCloud:自动删除 ${email} 失败:${getErrorMessage(err)}`, 'warn'); + return { handled: true, deleted: false }; + } +} + // ============================================================ // Tab Registry // ============================================================ @@ -1498,12 +3139,16 @@ function buildLocalhostCleanupPrefix(rawUrl) { async function closeTabsByUrlPrefix(prefix, options = {}) { if (!prefix) return 0; - const { excludeTabIds = [] } = options; + const { excludeTabIds = [], excludeUrls = [], excludeLocalhostCallbacks = false } = options; const excluded = new Set(excludeTabIds.filter(id => Number.isInteger(id))); + const excludedUrls = new Set((Array.isArray(excludeUrls) ? excludeUrls : []).filter(Boolean)); const tabs = await chrome.tabs.query({}); const matchedIds = tabs .filter((tab) => Number.isInteger(tab.id) && !excluded.has(tab.id)) + .filter((tab) => typeof tab.url === 'string' && !excludedUrls.has(tab.url)) + .filter((tab) => !(excludeLocalhostCallbacks && isLocalhostOAuthCallbackUrl(tab.url))) .filter((tab) => typeof tab.url === 'string' && tab.url.startsWith(prefix)) + .filter((tab) => !isLocalhostOAuthCallbackUrl(tab.url)) .map((tab) => tab.id); if (!matchedIds.length) return 0; @@ -1647,6 +3292,10 @@ function getContentScriptResponseTimeoutMs(message) { return 30000; } + if (message.type === 'EXECUTE_STEP' && Number(message.step) === 6) { + return 75000; + } + if (message.type === 'POLL_EMAIL') { const maxAttempts = Math.max(1, Number(message.payload?.maxAttempts) || 1); const intervalMs = Math.max(0, Number(message.payload?.intervalMs) || 0); @@ -2089,7 +3738,9 @@ function getSourceLabel(source) { 'mail-2925': '2925 邮箱', 'inbucket-mail': 'Inbucket 邮箱', 'duck-mail': 'Duck 邮箱', - 'hotmail-api': 'Hotmail(远程/本地)', + 'hotmail-api': 'Hotmail(API对接/本地助手)', + 'luckmail-api': 'LuckMail(API 购邮)', + 'cloudflare-temp-email': 'Cloudflare Temp Email', }; return labels[source] || source || '未知来源'; } @@ -2129,50 +3780,23 @@ function isVerificationMailPollingError(error) { return /未在 .*邮箱中找到新的匹配邮件|未在 Hotmail 收件箱中找到新的匹配验证码|邮箱轮询结束,但未获取到验证码|无法获取新的(?:注册|登录)验证码|页面未能重新就绪|页面通信异常|did not respond in \d+s/i.test(message); } -const STEP7_RESTART_FROM_STEP6_ERROR_CODE = 'STEP7_RESTART_FROM_STEP6'; -const STEP7_RESTART_FROM_STEP6_MARKER_PATTERN = /^STEP7_RESTART_FROM_STEP6::([^:]+)::(.*)$/; - -function createStep7RestartFromStep6Error(details = {}) { - const { reason = 'unknown', url = '' } = details || {}; - const reasonLabel = reason === 'login_timeout_error_page' - ? '检测到登录页超时报错' - : '步骤 7 请求回到步骤 6'; - const error = new Error(`步骤 7:${reasonLabel}。${url ? `URL: ${url}` : ''}`.trim()); - error.code = STEP7_RESTART_FROM_STEP6_ERROR_CODE; - error.restartReason = reason; - error.restartUrl = url; - return error; -} - -function parseStep7RestartFromStep6Marker(message) { - const normalized = getErrorMessage(message); - const match = normalized.match(STEP7_RESTART_FROM_STEP6_MARKER_PATTERN); - if (!match) { - return null; +function getLoginAuthStateLabel(state) { + switch (state) { + case 'verification_page': + return '登录验证码页'; + case 'password_page': + return '密码页'; + case 'email_page': + return '邮箱输入页'; + case 'login_timeout_error_page': + return '登录超时报错页'; + case 'oauth_consent_page': + return 'OAuth 授权页'; + case 'add_phone_page': + return '手机号页'; + default: + return '未知页面'; } - - return { - reason: match[1] || 'unknown', - url: match[2] || '', - }; -} - -function getStep7RestartFromStep6Error(result) { - if (result?.restartFromStep6) { - return createStep7RestartFromStep6Error(result); - } - - const parsed = parseStep7RestartFromStep6Marker(result?.error); - if (!parsed) { - return null; - } - - return createStep7RestartFromStep6Error(parsed); -} - -function isStep7RestartFromStep6Error(error) { - return error?.code === STEP7_RESTART_FROM_STEP6_ERROR_CODE - || Boolean(parseStep7RestartFromStep6Marker(error)); } function isRestartCurrentAttemptError(error) { @@ -2912,8 +4536,12 @@ async function handleMessage(message, sender) { case 'SAVE_SETTING': { const updates = buildPersistentSettingsPayload(message.payload || {}); + const sessionUpdates = buildLuckmailSessionSettingsPayload(message.payload || {}); await setPersistentSettings(updates); - await setState(updates); + await setState({ + ...updates, + ...sessionUpdates, + }); return { ok: true, state: await getState() }; } @@ -2983,6 +4611,41 @@ async function handleMessage(message, sender) { return { ok: true, ...result }; } + case 'LIST_LUCKMAIL_PURCHASES': { + const purchases = await listLuckmailPurchasesForManagement(); + return { ok: true, purchases }; + } + + case 'SELECT_LUCKMAIL_PURCHASE': { + const purchase = await selectLuckmailPurchase(message.payload?.purchaseId); + return { ok: true, purchase }; + } + + case 'SET_LUCKMAIL_PURCHASE_USED_STATE': { + const result = await setLuckmailPurchaseUsedState(message.payload?.purchaseId, Boolean(message.payload?.used)); + return { ok: true, ...result }; + } + + case 'SET_LUCKMAIL_PURCHASE_PRESERVED_STATE': { + const purchase = await setLuckmailPurchasePreservedState(message.payload?.purchaseId, Boolean(message.payload?.preserved)); + return { ok: true, purchase }; + } + + case 'SET_LUCKMAIL_PURCHASE_DISABLED_STATE': { + const purchase = await setLuckmailPurchaseDisabledState(message.payload?.purchaseId, Boolean(message.payload?.disabled)); + return { ok: true, purchase }; + } + + case 'BATCH_UPDATE_LUCKMAIL_PURCHASES': { + const result = await batchUpdateLuckmailPurchases(message.payload || {}); + return { ok: true, ...result }; + } + + case 'DISABLE_USED_LUCKMAIL_PURCHASES': { + const result = await disableUsedLuckmailPurchases(); + return { ok: true, ...result }; + } + // Side panel data updates case 'SET_EMAIL_STATE': { const state = await getState(); @@ -3026,6 +4689,41 @@ async function handleMessage(message, sender) { return { ok: true, email }; } + case 'CHECK_ICLOUD_SESSION': { + clearStopRequest(); + return await checkIcloudSession(); + } + + case 'LIST_ICLOUD_ALIASES': { + clearStopRequest(); + const aliases = await listIcloudAliases(); + return { ok: true, aliases }; + } + + case 'SET_ICLOUD_ALIAS_USED_STATE': { + clearStopRequest(); + const result = await setIcloudAliasUsedState(message.payload || {}); + return { ok: true, ...result }; + } + + case 'SET_ICLOUD_ALIAS_PRESERVED_STATE': { + clearStopRequest(); + const result = await setIcloudAliasPreservedState(message.payload || {}); + return { ok: true, ...result }; + } + + case 'DELETE_ICLOUD_ALIAS': { + clearStopRequest(); + const result = await deleteIcloudAlias(message.payload || {}); + return { ok: true, ...result }; + } + + case 'DELETE_USED_ICLOUD_ALIASES': { + clearStopRequest(); + const result = await deleteUsedIcloudAliases(); + return { ok: true, ...result }; + } + case 'STOP_FLOW': { await requestStop(); return { ok: true }; @@ -3105,10 +4803,23 @@ async function handleStepData(step, payload) { }); await addLog('当前 Hotmail 账号已自动标记为已用。', 'ok'); } + if (isLuckmailProvider(latestState)) { + const currentPurchase = getCurrentLuckmailPurchase(latestState); + if (currentPurchase?.id) { + await setLuckmailPurchaseUsedState(currentPurchase.id, true); + await addLog(`当前 LuckMail 邮箱 ${currentPurchase.email_address} 已在本地标记为已用。`, 'ok'); + } + await clearLuckmailRuntimeState({ clearEmail: true }); + await addLog('当前 LuckMail 邮箱运行态已清空,下轮将优先复用未用邮箱或重新购买邮箱。', 'ok'); + } const localhostPrefix = buildLocalhostCleanupPrefix(payload.localhostUrl); if (localhostPrefix) { - await closeTabsByUrlPrefix(localhostPrefix); + await closeTabsByUrlPrefix(localhostPrefix, { + excludeUrls: [payload.localhostUrl], + excludeLocalhostCallbacks: true, + }); } + await finalizeIcloudAliasAfterSuccessfulFlow(latestState); if (shouldUseCustomRegistrationEmail(latestState) && latestState.email) { await setEmailStateSilently(null); } @@ -3125,8 +4836,8 @@ async function handleStepData(step, payload) { const stepWaiters = new Map(); let resumeWaiter = null; const AUTO_RUN_SIGNAL_COMPLETION_TIMEOUT_MS = 120000; -const AUTO_RUN_BACKGROUND_COMPLETED_STEPS = new Set([4, 7, 8]); -const STEP_COMPLETION_SIGNAL_STEPS = new Set([1, 2, 3, 5, 6, 9]); +const AUTO_RUN_BACKGROUND_COMPLETED_STEPS = new Set([4, 6, 7, 8]); +const STEP_COMPLETION_SIGNAL_STEPS = new Set([1, 2, 3, 5, 9]); function waitForStepComplete(step, timeoutMs = 120000) { return new Promise((resolve, reject) => { @@ -3400,7 +5111,12 @@ function getEmailGeneratorLabel(generator) { if (generator === 'custom') { return '自定义邮箱'; } - return generator === 'cloudflare' ? 'Cloudflare 邮箱' : 'Duck 邮箱'; + if (generator === 'icloud') { + return 'iCloud 隐私邮箱'; + } + if (generator === 'cloudflare') return 'Cloudflare 邮箱'; + if (generator === CLOUDFLARE_TEMP_EMAIL_GENERATOR) return 'Cloudflare Temp Email'; + return 'Duck 邮箱'; } function generateCloudflareAliasLocalPart() { @@ -3440,6 +5156,107 @@ async function fetchCloudflareEmail(state, options = {}) { return aliasEmail; } +function ensureCloudflareTempEmailConfig(state, options = {}) { + const { + requireAdminAuth = false, + requireDomain = false, + } = options; + const config = getCloudflareTempEmailConfig(state); + if (!config.baseUrl) { + throw new Error('Cloudflare Temp Email 服务地址为空或格式无效。'); + } + if (requireAdminAuth && !config.adminAuth) { + throw new Error('Cloudflare Temp Email 缺少 Admin Auth。'); + } + if (requireDomain && !config.domain) { + throw new Error('Cloudflare Temp Email 域名为空或格式无效。'); + } + return config; +} + +async function requestCloudflareTempEmailJson(config, path, options = {}) { + const { + method = 'GET', + payload, + searchParams, + timeoutMs = 20000, + } = options; + + const url = new URL(joinCloudflareTempEmailUrl(config.baseUrl, path)); + if (searchParams && typeof searchParams === 'object') { + for (const [key, value] of Object.entries(searchParams)) { + if (value === undefined || value === null || value === '') continue; + url.searchParams.set(key, String(value)); + } + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(new Error('timeout')), timeoutMs); + + let response; + try { + response = await fetch(url.toString(), { + method, + headers: buildCloudflareTempEmailHeaders(config, { + json: payload !== undefined, + }), + body: payload !== undefined ? JSON.stringify(payload) : undefined, + signal: controller.signal, + }); + } catch (err) { + const errorMessage = err?.name === 'AbortError' + ? `Cloudflare Temp Email 请求超时(>${Math.round(timeoutMs / 1000)} 秒)` + : `Cloudflare Temp Email 请求失败:${err.message}`; + throw new Error(errorMessage); + } finally { + clearTimeout(timeoutId); + } + + const text = await response.text(); + let parsed; + try { + parsed = text ? JSON.parse(text) : {}; + } catch { + parsed = text; + } + + if (!response.ok) { + const payloadError = typeof parsed === 'object' && parsed + ? (parsed.message || parsed.error || parsed.msg) + : ''; + throw new Error(`Cloudflare Temp Email 请求失败:${payloadError || text || `HTTP ${response.status}`}`); + } + + return parsed; +} + +async function fetchCloudflareTempEmailAddress(state, options = {}) { + throwIfStopped(); + const latestState = state || await getState(); + const config = ensureCloudflareTempEmailConfig(latestState, { + requireAdminAuth: true, + requireDomain: true, + }); + const requestedName = String(options.localPart || options.name || '').trim().toLowerCase() || generateCloudflareAliasLocalPart(); + const payload = { + enablePrefix: true, + name: requestedName, + domain: config.domain, + }; + const result = await requestCloudflareTempEmailJson(config, '/admin/new_address', { + method: 'POST', + payload, + }); + const address = normalizeCloudflareTempEmailAddress(getCloudflareTempEmailAddressFromResponse(result)); + if (!address) { + throw new Error('Cloudflare Temp Email 未返回可用邮箱地址。'); + } + + await setEmailState(address); + await addLog(`Cloudflare Temp Email:已生成 ${address}`, 'ok'); + return address; +} + async function fetchDuckEmail(options = {}) { throwIfStopped(); const { generateNew = true } = options; @@ -3471,9 +5288,15 @@ async function fetchGeneratedEmail(state, options = {}) { if (generator === 'custom') { throw new Error('当前邮箱生成方式为自定义邮箱,请直接填写注册邮箱。'); } + if (generator === 'icloud') { + return fetchIcloudHideMyEmail(); + } if (generator === 'cloudflare') { return fetchCloudflareEmail(currentState, options); } + if (generator === CLOUDFLARE_TEMP_EMAIL_GENERATOR) { + return fetchCloudflareTempEmailAddress(currentState, options); + } return fetchDuckEmail(options); } @@ -3487,6 +5310,7 @@ let autoRunTotalRuns = 1; let autoRunAttemptRun = 0; const EMAIL_FETCH_MAX_ATTEMPTS = 5; const VERIFICATION_POLL_MAX_ROUNDS = 5; +const STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS = 25000; const AUTO_STEP_DELAYS = { 1: 2000, 2: 2000, @@ -3530,7 +5354,13 @@ async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) { return account.email; } - if (isGeneratedAliasProvider(currentState.mailProvider)) { + if (isLuckmailProvider(currentState)) { + const purchase = await ensureLuckmailPurchaseForFlow({ allowReuse: true }); + await addLog(`=== 目标 ${targetRun}/${totalRuns} 轮:LuckMail 邮箱已就绪:${purchase.email_address}(第 ${attemptRuns} 次尝试)===`, 'ok'); + return purchase.email_address; + } + + if (isGeneratedAliasProvider(currentState)) { if (!currentState.emailPrefix) { throw new Error('2925 邮箱前缀未设置,请先在侧边栏填写。'); } @@ -3576,7 +5406,10 @@ async function ensureAutoEmailReady(targetRun, totalRuns, attemptRuns) { } catch (err) { lastError = err; await addLog(`${generatorLabel}自动获取失败(${attempt}/${EMAIL_FETCH_MAX_ATTEMPTS}):${err.message}`, 'warn'); - if (generator === 'cloudflare' && /域名/.test(String(err.message || ''))) { + if ( + (generator === 'cloudflare' && /域名/.test(String(err.message || ''))) + || (generator === CLOUDFLARE_TEMP_EMAIL_GENERATOR && /(服务地址|Admin Auth|域名)/.test(String(err.message || ''))) + ) { break; } } @@ -4656,7 +6489,10 @@ async function executeStep3(state) { preferredAccountId: state.currentHotmailAccountId || null, }); resolvedEmail = account.email; - } else if (isGeneratedAliasProvider(state.mailProvider)) { + } else if (isLuckmailProvider(state)) { + const purchase = await ensureLuckmailPurchaseForFlow({ allowReuse: true }); + resolvedEmail = purchase.email_address; + } else if (isGeneratedAliasProvider(state)) { resolvedEmail = buildGeneratedAliasEmail(state); } @@ -4698,6 +6534,12 @@ function getMailConfig(state) { if (provider === HOTMAIL_PROVIDER) { return { provider: HOTMAIL_PROVIDER, label: 'Hotmail(API对接/本地助手)' }; } + if (provider === LUCKMAIL_PROVIDER) { + return { provider: LUCKMAIL_PROVIDER, label: 'LuckMail(API 购邮)' }; + } + if (provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER) { + return { provider: CLOUDFLARE_TEMP_EMAIL_PROVIDER, label: 'Cloudflare Temp Email' }; + } 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 邮箱' }; } @@ -4830,17 +6672,15 @@ async function requestVerificationCodeResend(step) { payload: {}, }); - if (step === 7) { - const restartError = getStep7RestartFromStep6Error(result); - if (restartError) { - throw restartError; - } - } - if (result && result.error) { throw new Error(result.error); } + const requestedAt = Date.now(); + if (step === 7) { + await setState({ loginVerificationRequestedAt: requestedAt }); + } + const currentState = await getState(); if (currentState.mailProvider === '2925') { const mailTabId = await getTabId('mail-2925'); @@ -4850,18 +6690,36 @@ async function requestVerificationCodeResend(step) { } } - return Date.now(); + return requestedAt; } async function pollFreshVerificationCode(step, state, mail, pollOverrides = {}) { + const { onResendRequestedAt, ...cleanPollOverrides } = pollOverrides; + if (mail.provider === HOTMAIL_PROVIDER) { const hotmailPollConfig = getHotmailVerificationPollConfig(step); return pollHotmailVerificationCode(step, state, { ...getVerificationPollPayload(step, state), ...hotmailPollConfig, + ...cleanPollOverrides, + }); + } + if (mail.provider === LUCKMAIL_PROVIDER) { + return pollLuckmailVerificationCode(step, state, { + ...getVerificationPollPayload(step, state), ...pollOverrides, }); } + if (mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER) { + return pollCloudflareTempEmailVerificationCode(step, state, { + ...getVerificationPollPayload(step, state), + ...pollOverrides, + }); + } + + if (Number(pollOverrides.resendIntervalMs) > 0) { + return pollFreshVerificationCodeWithResendInterval(step, state, mail, pollOverrides); + } const stateKey = getVerificationCodeStateKey(step); const rejectedCodes = new Set(); @@ -4873,17 +6731,23 @@ async function pollFreshVerificationCode(step, state, mail, pollOverrides = {}) } let lastError = null; - const filterAfterTimestamp = pollOverrides.filterAfterTimestamp ?? getVerificationPollPayload(step, state).filterAfterTimestamp; + let filterAfterTimestamp = cleanPollOverrides.filterAfterTimestamp ?? getVerificationPollPayload(step, state).filterAfterTimestamp; const maxRounds = pollOverrides.maxRounds || VERIFICATION_POLL_MAX_ROUNDS; for (let round = 1; round <= maxRounds; round++) { throwIfStopped(); if (round > 1) { - await requestVerificationCodeResend(step); + const requestedAt = await requestVerificationCodeResend(step); + if (typeof onResendRequestedAt === 'function') { + const nextFilterAfterTimestamp = await onResendRequestedAt(requestedAt); + if (nextFilterAfterTimestamp !== undefined) { + filterAfterTimestamp = nextFilterAfterTimestamp; + } + } } const payload = getVerificationPollPayload(step, state, { - ...pollOverrides, + ...cleanPollOverrides, filterAfterTimestamp, excludeCodes: [...rejectedCodes], }); @@ -4931,6 +6795,119 @@ async function pollFreshVerificationCode(step, state, mail, pollOverrides = {}) throw lastError || new Error(`步骤 ${step}:无法获取新的${getVerificationCodeLabel(step)}验证码。`); } +async function pollFreshVerificationCodeWithResendInterval(step, state, mail, pollOverrides = {}) { + const stateKey = getVerificationCodeStateKey(step); + const rejectedCodes = new Set(); + if (state[stateKey]) { + rejectedCodes.add(state[stateKey]); + } + for (const code of (pollOverrides.excludeCodes || [])) { + if (code) rejectedCodes.add(code); + } + + const { + maxRounds: _ignoredMaxRounds, + resendIntervalMs: _ignoredResendIntervalMs, + lastResendAt: _ignoredLastResendAt, + onResendRequestedAt: _ignoredOnResendRequestedAt, + ...payloadOverrides + } = pollOverrides; + const onResendRequestedAt = typeof pollOverrides.onResendRequestedAt === 'function' + ? pollOverrides.onResendRequestedAt + : null; + let lastError = null; + let filterAfterTimestamp = payloadOverrides.filterAfterTimestamp ?? getVerificationPollPayload(step, state).filterAfterTimestamp; + const maxRounds = pollOverrides.maxRounds || VERIFICATION_POLL_MAX_ROUNDS; + const resendIntervalMs = Math.max(0, Number(pollOverrides.resendIntervalMs) || 0); + let lastResendAt = Number(pollOverrides.lastResendAt) || 0; + + for (let round = 1; round <= maxRounds; round++) { + throwIfStopped(); + if (round > 1) { + lastResendAt = await requestVerificationCodeResend(step); + if (onResendRequestedAt) { + const nextFilterAfterTimestamp = await onResendRequestedAt(lastResendAt); + if (nextFilterAfterTimestamp !== undefined) { + filterAfterTimestamp = nextFilterAfterTimestamp; + } + } + } + + while (true) { + throwIfStopped(); + const payload = getVerificationPollPayload(step, state, { + ...payloadOverrides, + filterAfterTimestamp, + excludeCodes: [...rejectedCodes], + }); + + if (lastResendAt > 0) { + const remainingBeforeResendMs = Math.max(0, resendIntervalMs - (Date.now() - lastResendAt)); + const baseMaxAttempts = Math.max(1, Number(payload.maxAttempts) || 5); + const intervalMs = Math.max(1, Number(payload.intervalMs) || 3000); + payload.maxAttempts = Math.max(1, Math.min(baseMaxAttempts, Math.floor(remainingBeforeResendMs / intervalMs) + 1)); + } + + try { + const result = await sendToMailContentScriptResilient( + mail, + { + type: 'POLL_EMAIL', + step, + source: 'background', + payload, + }, + { + timeoutMs: 45000, + maxRecoveryAttempts: 2, + } + ); + + if (result && result.error) { + throw new Error(result.error); + } + + if (!result || !result.code) { + throw new Error(`步骤 ${step}:邮箱轮询结束,但未获取到验证码。`); + } + + if (rejectedCodes.has(result.code)) { + throw new Error(`步骤 ${step}:再次收到了相同的${getVerificationCodeLabel(step)}验证码:${result.code}`); + } + + return { + ...result, + lastResendAt, + }; + } catch (err) { + if (isStopError(err)) { + throw err; + } + lastError = err; + await addLog(`步骤 ${step}:${err.message}`, 'warn'); + } + + const remainingBeforeResendMs = lastResendAt > 0 + ? Math.max(0, resendIntervalMs - (Date.now() - lastResendAt)) + : 0; + if (remainingBeforeResendMs > 0) { + await addLog( + `步骤 ${step}:距离下次重新发送验证码还差 ${Math.ceil(remainingBeforeResendMs / 1000)} 秒,继续刷新邮箱(第 ${round}/${maxRounds} 轮)...`, + 'info' + ); + continue; + } + + if (round < maxRounds) { + await addLog(`步骤 ${step}:已到 25 秒重发间隔,准备重新发送验证码(第 ${round + 1}/${maxRounds} 轮)...`, 'warn'); + } + break; + } + } + + throw lastError || new Error(`步骤 ${step}:无法获取新的${getVerificationCodeLabel(step)}验证码。`); +} + async function submitVerificationCode(step, code) { const signupTabId = await getTabId('signup-page'); if (!signupTabId) { @@ -4945,13 +6922,6 @@ async function submitVerificationCode(step, code) { payload: { code }, }); - if (step === 7) { - const restartError = getStep7RestartFromStep6Error(result); - if (restartError) { - throw restartError; - } - } - if (result && result.error) { throw new Error(result.error); } @@ -4970,18 +6940,38 @@ async function resolveVerificationStep(step, state, mail, options = {}) { rejectedCodes.add(state[stateKey]); } - const nextFilterAfterTimestamp = options.filterAfterTimestamp ?? null; + let nextFilterAfterTimestamp = options.filterAfterTimestamp ?? null; const requestFreshCodeFirst = options.requestFreshCodeFirst !== undefined ? Boolean(options.requestFreshCodeFirst) : (hotmailPollConfig?.requestFreshCodeFirst ?? false); const maxSubmitAttempts = 3; + const resendIntervalMs = Math.max(0, Number(options.resendIntervalMs) || 0); + let lastResendAt = Number(options.lastResendAt) || 0; + + const updateFilterAfterTimestampForStep7 = async (requestedAt) => { + if (step !== 7 || !requestedAt) { + return nextFilterAfterTimestamp; + } + + if (mail.provider === HOTMAIL_PROVIDER) { + nextFilterAfterTimestamp = getHotmailVerificationRequestTimestamp(7, { + ...state, + loginVerificationRequestedAt: requestedAt, + }); + } else { + nextFilterAfterTimestamp = Math.max(0, Number(requestedAt) - 60000); + } + + return nextFilterAfterTimestamp; + }; if (requestFreshCodeFirst) { try { - await requestVerificationCodeResend(step); + lastResendAt = await requestVerificationCodeResend(step); + await updateFilterAfterTimestampForStep7(lastResendAt); await addLog(`步骤 ${step}:已先请求一封新的${getVerificationCodeLabel(step)}验证码,再开始轮询邮箱。`, 'warn'); } catch (err) { - if (isStopError(err) || (step === 7 && isStep7RestartFromStep6Error(err))) { + if (isStopError(err)) { throw err; } await addLog(`步骤 ${step}:首次重新获取验证码失败:${err.message},将继续使用当前时间窗口轮询。`, 'warn'); @@ -5000,7 +6990,11 @@ async function resolveVerificationStep(step, state, mail, options = {}) { const result = await pollFreshVerificationCode(step, state, mail, { excludeCodes: [...rejectedCodes], filterAfterTimestamp: nextFilterAfterTimestamp ?? undefined, + resendIntervalMs, + lastResendAt, + onResendRequestedAt: updateFilterAfterTimestampForStep7, }); + lastResendAt = Number(result?.lastResendAt) || lastResendAt; throwIfStopped(); await addLog(`步骤 ${step}:已获取${getVerificationCodeLabel(step)}验证码:${result.code}`); @@ -5015,7 +7009,19 @@ async function resolveVerificationStep(step, state, mail, options = {}) { throw new Error(`步骤 ${step}:验证码连续失败,已达到 ${maxSubmitAttempts} 次重试上限。`); } - await requestVerificationCodeResend(step); + const remainingBeforeResendMs = resendIntervalMs > 0 && lastResendAt > 0 + ? Math.max(0, resendIntervalMs - (Date.now() - lastResendAt)) + : 0; + if (remainingBeforeResendMs > 0) { + await addLog( + `步骤 ${step}:提交失败后距离下次重新发送验证码还差 ${Math.ceil(remainingBeforeResendMs / 1000)} 秒,先继续刷新邮箱(${attempt + 1}/${maxSubmitAttempts})...`, + 'warn' + ); + continue; + } + + lastResendAt = await requestVerificationCodeResend(step); + await updateFilterAfterTimestampForStep7(lastResendAt); await addLog(`步骤 ${step}:提交失败后已请求新验证码(${attempt + 1}/${maxSubmitAttempts})...`, 'warn'); continue; } @@ -5063,9 +7069,6 @@ 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; @@ -5077,7 +7080,7 @@ async function executeStep4(state) { } throwIfStopped(); - if (mail.provider === HOTMAIL_PROVIDER) { + if (mail.provider === HOTMAIL_PROVIDER || mail.provider === LUCKMAIL_PROVIDER || mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER) { await addLog(`步骤 4:正在通过 ${mail.label} 轮询验证码...`); } else { await addLog(`步骤 4:正在打开${mail.label}...`); @@ -5105,6 +7108,7 @@ async function executeStep4(state) { await resolveVerificationStep(4, state, mail, { filterAfterTimestamp: mail.provider === HOTMAIL_PROVIDER ? undefined : stepStartedAt, requestFreshCodeFirst: mail.provider === HOTMAIL_PROVIDER ? false : true, + resendIntervalMs: mail.provider === HOTMAIL_PROVIDER ? 0 : STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS, }); return; } @@ -5128,7 +7132,7 @@ async function executeStep5(state) { } // ============================================================ -// Step 6: Login ChatGPT (Background opens tab, chatgpt.js handles login) +// Step 6: Login and ensure the auth page reaches the login verification page // ============================================================ async function refreshOAuthUrlBeforeStep6(state) { @@ -5149,28 +7153,110 @@ async function refreshOAuthUrlBeforeStep6(state) { return latestState.oauthUrl; } +function isStep6SuccessResult(result) { + return result?.step6Outcome === 'success'; +} + +function isStep6RecoverableResult(result) { + return result?.step6Outcome === 'recoverable'; +} + +async function getLoginAuthStateFromContent() { + const result = await sendToContentScriptResilient( + 'signup-page', + { + type: 'GET_LOGIN_AUTH_STATE', + source: 'background', + payload: {}, + }, + { + timeoutMs: 15000, + retryDelayMs: 600, + logMessage: '步骤 7:认证页正在切换,等待页面重新就绪后继续确认验证码页状态...', + } + ); + + if (result?.error) { + throw new Error(result.error); + } + + return result || {}; +} + +async function ensureStep7VerificationPageReady() { + const pageState = await getLoginAuthStateFromContent(); + if (pageState.state === 'verification_page') { + return pageState; + } + + const stateLabel = getLoginAuthStateLabel(pageState.state); + const urlPart = pageState.url ? ` URL: ${pageState.url}` : ''; + throw new Error(`当前未进入登录验证码页面,请先重新完成步骤 6。当前状态:${stateLabel}.${urlPart}`.trim()); +} + async function executeStep6(state) { if (!state.email) { throw new Error('缺少邮箱地址,请先完成步骤 3。'); } + let attempt = 0; - const oauthUrl = await refreshOAuthUrlBeforeStep6(state); + while (true) { + throwIfStopped(); + attempt += 1; + const currentState = attempt === 1 ? state : await getState(); + const password = currentState.password || currentState.customPassword || ''; + const oauthUrl = await refreshOAuthUrlBeforeStep6(currentState); - await addLog('步骤 6:正在打开最新 OAuth 链接并登录...'); - // Reuse the signup-page tab — navigate it to the OAuth URL - await reuseOrCreateTab('signup-page', oauthUrl); + if (attempt === 1) { + await addLog('步骤 6:正在打开最新 OAuth 链接并登录...'); + } else { + await addLog(`步骤 6:上一轮登录未进入验证码页,正在重新发起第 ${attempt} 轮登录尝试...`, 'warn'); + } - // signup-page.js will inject (same auth.openai.com domain) and handle login - await sendToContentScript('signup-page', { - type: 'EXECUTE_STEP', - step: 6, - source: 'background', - payload: { email: state.email, password: state.password }, - }); + await reuseOrCreateTab('signup-page', oauthUrl); + + const result = await sendToContentScriptResilient( + 'signup-page', + { + type: 'EXECUTE_STEP', + step: 6, + source: 'background', + payload: { + email: currentState.email, + password, + }, + }, + { + timeoutMs: 180000, + retryDelayMs: 700, + logMessage: '步骤 6:认证页正在切换,等待页面重新就绪后继续登录...', + } + ); + + if (result?.error) { + throw new Error(result.error); + } + + if (isStep6SuccessResult(result)) { + await completeStepFromBackground(6, { + loginVerificationRequestedAt: result.loginVerificationRequestedAt || null, + }); + return; + } + + if (isStep6RecoverableResult(result)) { + const reasonMessage = result.message + || `当前停留在${getLoginAuthStateLabel(result.state)},准备重新执行步骤 6。`; + await addLog(`步骤 6:${reasonMessage}`, 'warn'); + continue; + } + + throw new Error('步骤 6:认证页未返回可识别的登录结果。'); + } } // ============================================================ -// Step 7: Get Login Verification Code (qq-mail.js polls, then fills in chatgpt.js) +// Step 7: Poll login verification mail and submit the login code // ============================================================ async function runStep7Attempt(state) { @@ -5189,22 +7275,8 @@ async function runStep7Attempt(state) { } throwIfStopped(); - await addLog('步骤 7:正在准备认证页,必要时切换到一次性验证码登录...'); - const prepareResult = await sendToContentScript('signup-page', { - type: 'PREPARE_LOGIN_CODE', - step: 7, - source: 'background', - payload: {}, - }); - - const restartError = getStep7RestartFromStep6Error(prepareResult); - if (restartError) { - throw restartError; - } - - if (prepareResult && prepareResult.error) { - throw new Error(prepareResult.error); - } + await ensureStep7VerificationPageReady(); + await addLog('步骤 7:登录验证码页面已就绪,开始获取验证码。', 'info'); if (shouldUseCustomRegistrationEmail(state)) { await confirmCustomVerificationStepBypass(7); @@ -5212,7 +7284,7 @@ async function runStep7Attempt(state) { } throwIfStopped(); - if (mail.provider === HOTMAIL_PROVIDER) { + if (mail.provider === HOTMAIL_PROVIDER || mail.provider === LUCKMAIL_PROVIDER || mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER) { await addLog(`步骤 7:正在通过 ${mail.label} 轮询验证码...`); } else { await addLog(`步骤 7:正在打开${mail.label}...`); @@ -5237,17 +7309,16 @@ async function runStep7Attempt(state) { } await resolveVerificationStep(7, state, mail, { - filterAfterTimestamp: mail.provider === HOTMAIL_PROVIDER ? undefined : stepStartedAt, - requestFreshCodeFirst: mail.provider === HOTMAIL_PROVIDER ? false : true, + filterAfterTimestamp: mail.provider === HOTMAIL_PROVIDER ? undefined : Math.max(0, stepStartedAt - 60000), + requestFreshCodeFirst: false, + resendIntervalMs: mail.provider === HOTMAIL_PROVIDER ? 0 : STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS, }); } async function rerunStep6ForStep7Recovery() { const currentState = await getState(); - const waitForStep6 = waitForStepComplete(6, 120000); await addLog('步骤 7:正在回到步骤 6,重新发起登录验证码流程...', 'warn'); await executeStep6(currentState); - await waitForStep6; await sleepWithStop(3000); } @@ -5261,13 +7332,6 @@ async function executeStep7(state) { await runStep7Attempt(currentState); return; } catch (err) { - if (isStep7RestartFromStep6Error(err)) { - await addLog('步骤 7:检测到登录页超时报错,准备从步骤 6 重新开始...', 'warn'); - await rerunStep6ForStep7Recovery(); - currentState = await getState(); - continue; - } - if (!isVerificationMailPollingError(err)) { throw err; } diff --git a/cloudflare-temp-email-utils.js b/cloudflare-temp-email-utils.js new file mode 100644 index 0000000..e7a8992 --- /dev/null +++ b/cloudflare-temp-email-utils.js @@ -0,0 +1,400 @@ +(function cloudflareTempEmailUtilsModule(root, factory) { + if (typeof module !== 'undefined' && module.exports) { + module.exports = factory(); + return; + } + + root.CloudflareTempEmailUtils = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createCloudflareTempEmailUtils() { + const DEFAULT_MAIL_PAGE_SIZE = 20; + + 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 normalizeCloudflareTempEmailBaseUrl(rawValue = '') { + const value = String(rawValue || '').trim(); + if (!value) return ''; + + const candidate = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(value) ? value : `https://${value}`; + try { + const parsed = new URL(candidate); + parsed.hash = ''; + parsed.search = ''; + const pathname = parsed.pathname === '/' ? '' : parsed.pathname.replace(/\/+$/, ''); + return `${parsed.origin}${pathname}`; + } catch { + return ''; + } + } + + function normalizeCloudflareTempEmailDomain(rawValue = '') { + let value = String(rawValue || '').trim().toLowerCase(); + if (!value) return ''; + value = value.replace(/^@+/, ''); + value = value.replace(/^https?:\/\//, ''); + value = value.replace(/\/.*$/, ''); + if (!/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(value)) { + return ''; + } + return value; + } + + function normalizeCloudflareTempEmailDomains(values) { + const domains = []; + const seen = new Set(); + for (const value of Array.isArray(values) ? values : []) { + const normalized = normalizeCloudflareTempEmailDomain(value); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + domains.push(normalized); + } + return domains; + } + + function buildCloudflareTempEmailHeaders(config = {}, options = {}) { + const headers = {}; + const adminAuth = firstNonEmptyString([config.adminAuth, config.cloudflareTempEmailAdminAuth]); + const customAuth = firstNonEmptyString([config.customAuth, config.cloudflareTempEmailCustomAuth]); + if (adminAuth) { + headers['x-admin-auth'] = adminAuth; + } + if (customAuth) { + headers['x-custom-auth'] = customAuth; + } + if (options.json) { + headers['Content-Type'] = 'application/json'; + } + if (options.acceptJson !== false) { + headers.Accept = 'application/json'; + } + return headers; + } + + function joinCloudflareTempEmailUrl(baseUrl, path) { + const normalizedBase = normalizeCloudflareTempEmailBaseUrl(baseUrl); + const normalizedPath = String(path || '').trim(); + if (!normalizedBase || !normalizedPath) return normalizedBase || ''; + return `${normalizedBase}${normalizedPath.startsWith('/') ? '' : '/'}${normalizedPath}`; + } + + function getCloudflareTempEmailMailRows(payload) { + if (Array.isArray(payload)) return payload; + if (!payload || typeof payload !== 'object') return []; + + const candidates = [ + payload.data, + payload.items, + payload.messages, + payload.mails, + payload.results, + payload.rows, + ]; + + for (const candidate of candidates) { + if (Array.isArray(candidate)) { + return candidate; + } + } + + return []; + } + + function normalizeCloudflareTempEmailAddress(value) { + return String(value || '').trim().toLowerCase(); + } + + function splitRawMessage(raw = '') { + const source = String(raw || ''); + if (!source) { + return { headerText: '', bodyText: '' }; + } + + const normalized = source.replace(/\r\n/g, '\n'); + const separatorIndex = normalized.indexOf('\n\n'); + if (separatorIndex === -1) { + return { headerText: normalized, bodyText: '' }; + } + + return { + headerText: normalized.slice(0, separatorIndex), + bodyText: normalized.slice(separatorIndex + 2), + }; + } + + function parseRawHeaders(headerText = '') { + const headers = {}; + const lines = String(headerText || '').split('\n'); + let currentName = ''; + + for (const line of lines) { + if (!line) continue; + if ((line.startsWith(' ') || line.startsWith('\t')) && currentName) { + headers[currentName] += ` ${line.trim()}`; + continue; + } + + const separatorIndex = line.indexOf(':'); + if (separatorIndex <= 0) continue; + currentName = line.slice(0, separatorIndex).trim().toLowerCase(); + headers[currentName] = line.slice(separatorIndex + 1).trim(); + } + + return headers; + } + + function decodeMimeEncodedWords(value = '') { + const source = String(value || ''); + return source.replace(/=\?([^?]+)\?([bBqQ])\?([^?]+)\?=/g, (_match, charset, encoding, encodedText) => { + try { + if (String(encoding).toUpperCase() === 'B') { + return decodeBytesToString(base64ToBytes(encodedText), charset); + } + return decodeBytesToString( + quotedPrintableToBytes(String(encodedText).replace(/_/g, ' '), { headerMode: true }), + charset + ); + } catch { + return encodedText; + } + }); + } + + function base64ToBytes(value = '') { + const normalized = String(value || '').replace(/\s+/g, ''); + if (!normalized) return new Uint8Array(); + + if (typeof atob === 'function') { + const decoded = atob(normalized); + const bytes = new Uint8Array(decoded.length); + for (let i = 0; i < decoded.length; i += 1) { + bytes[i] = decoded.charCodeAt(i); + } + return bytes; + } + + if (typeof Buffer !== 'undefined') { + return Uint8Array.from(Buffer.from(normalized, 'base64')); + } + + throw new Error('No base64 decoder available'); + } + + function quotedPrintableToBytes(value = '', options = {}) { + const { headerMode = false } = options; + const source = String(value || '') + .replace(/=\r?\n/g, '') + .replace(headerMode ? /_/g : /$^/, ' '); + + const bytes = []; + for (let index = 0; index < source.length; index += 1) { + const char = source[index]; + if (char === '=' && /^[0-9A-Fa-f]{2}$/.test(source.slice(index + 1, index + 3))) { + bytes.push(parseInt(source.slice(index + 1, index + 3), 16)); + index += 2; + continue; + } + bytes.push(char.charCodeAt(0)); + } + return Uint8Array.from(bytes); + } + + function decodeBytesToString(bytes, charset = 'utf-8') { + const normalizedCharset = String(charset || 'utf-8').trim().toLowerCase(); + const candidates = [normalizedCharset]; + if (normalizedCharset === 'utf8') { + candidates.unshift('utf-8'); + } + if (normalizedCharset === 'gb2312' || normalizedCharset === 'gbk') { + candidates.unshift('gb18030'); + } + + for (const candidate of candidates) { + try { + if (typeof TextDecoder !== 'undefined') { + return new TextDecoder(candidate, { fatal: false }).decode(bytes); + } + } catch { + // ignore and try fallback + } + } + + if (typeof Buffer !== 'undefined') { + return Buffer.from(bytes).toString('utf8'); + } + + let result = ''; + for (const byte of bytes) { + result += String.fromCharCode(byte); + } + return result; + } + + function getCharsetFromContentType(contentType = '') { + const match = String(contentType || '').match(/charset="?([^";]+)"?/i); + return match ? match[1].trim() : 'utf-8'; + } + + function getBoundaryFromContentType(contentType = '') { + const match = String(contentType || '').match(/boundary="?([^";]+)"?/i); + return match ? match[1] : ''; + } + + function stripHtmlTags(value = '') { + return String(value || '') + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/\s+/g, ' ') + .trim(); + } + + function decodeMimeBody(bodyText = '', headers = {}) { + const contentType = String(headers['content-type'] || ''); + const transferEncoding = String(headers['content-transfer-encoding'] || '').trim().toLowerCase(); + const charset = getCharsetFromContentType(contentType); + let decoded = String(bodyText || ''); + + if (transferEncoding === 'base64') { + decoded = decodeBytesToString(base64ToBytes(decoded), charset); + } else if (transferEncoding === 'quoted-printable') { + decoded = decodeBytesToString(quotedPrintableToBytes(decoded), charset); + } + + if (/text\/html/i.test(contentType)) { + return stripHtmlTags(decoded); + } + return decoded.replace(/\s+/g, ' ').trim(); + } + + function extractTextFromMime(rawMessage = '', depth = 0) { + const { headerText, bodyText } = splitRawMessage(rawMessage); + const headers = parseRawHeaders(headerText); + const contentType = String(headers['content-type'] || ''); + const boundary = getBoundaryFromContentType(contentType); + + if (/multipart\//i.test(contentType) && boundary && depth < 6) { + const marker = `--${boundary}`; + const sections = String(bodyText || '') + .split(marker) + .map((part) => part.trim()) + .filter((part) => part && part !== '--'); + + const extractedParts = sections + .map((part) => part.replace(/--\s*$/, '').trim()) + .map((part) => extractTextFromMime(part, depth + 1)?.text || '') + .filter(Boolean); + + const plainText = extractedParts.join(' ').replace(/\s+/g, ' ').trim(); + return { + headers, + text: plainText, + }; + } + + return { + headers, + text: decodeMimeBody(bodyText, headers), + }; + } + + function normalizeReceivedDateTime(value) { + if (!value && value !== 0) return ''; + if (typeof value === 'number' && Number.isFinite(value)) { + return new Date(value).toISOString(); + } + const source = String(value || '').trim(); + if (!source) return ''; + const parsed = Date.parse(source); + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : source; + } + + function normalizeCloudflareTempEmailMessage(row = {}) { + if (!row || typeof row !== 'object') return null; + + const address = normalizeCloudflareTempEmailAddress(firstNonEmptyString([ + row.address, + row.mail_address, + row.email, + row.recipient, + ])); + const raw = firstNonEmptyString([row.raw, row.source, row.mime, row.message]); + const parsedMime = raw ? extractTextFromMime(raw) : { headers: {}, text: '' }; + const subject = decodeMimeEncodedWords(firstNonEmptyString([ + row.subject, + parsedMime.headers.subject, + ])); + const fromAddress = decodeMimeEncodedWords(firstNonEmptyString([ + row.from, + row.sender, + row.mail_from, + parsedMime.headers.from, + ])); + const bodyPreview = firstNonEmptyString([ + row.text, + row.preview, + row.body, + parsedMime.text, + raw, + ]).replace(/\s+/g, ' ').trim(); + + return { + id: firstNonEmptyString([row.id, row.mail_id]), + address, + addressId: firstNonEmptyString([row.address_id, row.addressId]), + subject, + from: { + emailAddress: { + address: fromAddress, + }, + }, + bodyPreview, + raw, + receivedDateTime: normalizeReceivedDateTime(firstNonEmptyString([ + row.receivedDateTime, + row.received_at, + row.created_at, + row.createdAt, + row.updated_at, + row.date, + ])), + }; + } + + function normalizeCloudflareTempEmailMailApiMessages(payload) { + return getCloudflareTempEmailMailRows(payload) + .map((row) => normalizeCloudflareTempEmailMessage(row)) + .filter(Boolean); + } + + function getCloudflareTempEmailAddressFromResponse(payload = {}) { + return firstNonEmptyString([ + payload.address, + payload.email, + payload?.data?.address, + payload?.data?.email, + ]); + } + + return { + DEFAULT_MAIL_PAGE_SIZE, + buildCloudflareTempEmailHeaders, + getCloudflareTempEmailAddressFromResponse, + joinCloudflareTempEmailUrl, + normalizeCloudflareTempEmailAddress, + normalizeCloudflareTempEmailBaseUrl, + normalizeCloudflareTempEmailDomain, + normalizeCloudflareTempEmailDomains, + normalizeCloudflareTempEmailMailApiMessages, + normalizeCloudflareTempEmailMessage, + }; +}); diff --git a/content/signup-page.js b/content/signup-page.js index ea2de6b..6c56621 100644 --- a/content/signup-page.js +++ b/content/signup-page.js @@ -11,7 +11,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { || message.type === 'STEP8_FIND_AND_CLICK' || message.type === 'STEP8_GET_STATE' || message.type === 'STEP8_TRIGGER_CONTINUE' - || message.type === 'PREPARE_LOGIN_CODE' + || message.type === 'GET_LOGIN_AUTH_STATE' || message.type === 'PREPARE_SIGNUP_VERIFICATION' || message.type === 'RESEND_VERIFICATION_CODE' ) { @@ -52,10 +52,10 @@ async function handleCommand(message) { case 'FILL_CODE': // Step 4 = signup code, Step 7 = login code (same handler) return await fillVerificationCode(message.step, message.payload); + case 'GET_LOGIN_AUTH_STATE': + return serializeLoginAuthState(inspectLoginAuthState()); case 'PREPARE_SIGNUP_VERIFICATION': return await prepareSignupVerificationFlow(message.payload); - case 'PREPARE_LOGIN_CODE': - return await prepareLoginCodeFlow(); case 'RESEND_VERIFICATION_CODE': return await resendVerificationCode(message.step); case 'STEP8_FIND_AND_CLICK': @@ -176,86 +176,9 @@ function isEmailVerificationPage() { return /\/email-verification(?:[/?#]|$)/i.test(location.pathname || ''); } -async function prepareLoginCodeFlow(timeout = 15000) { - const readyTarget = getVerificationCodeTarget(); - if (readyTarget) { - log('步骤 7:验证码输入框已就绪。'); - return { ready: true, mode: readyTarget.type }; - } - - if (isEmailVerificationPage() && isVerificationPageStillVisible()) { - log('步骤 7:已进入邮箱验证码页面,正在等待验证码输入框或重发入口稳定。'); - return { ready: true, mode: 'verification_page' }; - } - - const initialRestartSignal = getStep7RestartFromStep6Signal(); - if (initialRestartSignal) { - log('步骤 7:检测到登录页超时报错,准备回到步骤 6 重新发起登录验证码流程...', 'warn'); - return initialRestartSignal; - } - - const start = Date.now(); - let switchClickCount = 0; - let lastSwitchAttemptAt = 0; - let loggedPasswordPage = false; - let loggedVerificationPage = false; - - while (Date.now() - start < timeout) { - throwIfStopped(); - - const target = getVerificationCodeTarget(); - if (target) { - log('步骤 7:验证码页面已就绪。'); - return { ready: true, mode: target.type }; - } - - if (isEmailVerificationPage() && isVerificationPageStillVisible()) { - if (!loggedVerificationPage) { - loggedVerificationPage = true; - log('步骤 7:页面已进入邮箱验证码流程,继续等待验证码输入框渲染...'); - } - await sleep(250); - continue; - } - - const restartSignal = getStep7RestartFromStep6Signal(); - if (restartSignal) { - log('步骤 7:检测到登录页超时报错,准备回到步骤 6 重新发起登录验证码流程...', 'warn'); - return restartSignal; - } - - const passwordInput = document.querySelector('input[type="password"]'); - const switchTrigger = findOneTimeCodeLoginTrigger(); - - if (switchTrigger && (switchClickCount === 0 || Date.now() - lastSwitchAttemptAt > 1500)) { - switchClickCount += 1; - lastSwitchAttemptAt = Date.now(); - loggedPasswordPage = false; - log('步骤 7:检测到密码页,正在切换到一次性验证码登录...'); - await humanPause(350, 900); - const verificationRequestedAt = Date.now(); - simulateClick(switchTrigger); - await sleep(1200); - return { ready: true, mode: 'verification_switch', verificationRequestedAt }; - } - - if (passwordInput && !loggedPasswordPage) { - loggedPasswordPage = true; - log('步骤 7:正在等待密码页上的一次性验证码登录入口...'); - } - - await sleep(200); - } - - throw new Error('无法切换到一次性验证码验证页面。URL: ' + location.href); -} - async function resendVerificationCode(step, timeout = 45000) { if (step === 7) { - const prepareResult = await prepareLoginCodeFlow(); - if (prepareResult?.restartFromStep6) { - return prepareResult; - } + await waitForLoginVerificationPageReady(); } const start = Date.now(); @@ -730,26 +653,248 @@ function getLoginTimeoutErrorPageState() { }); } -function isSignupPasswordErrorPage() { - return Boolean(getSignupPasswordTimeoutErrorPageState()); +function getLoginEmailInput() { + const input = document.querySelector( + 'input[type="email"], input[name="email"], input[name="username"], input[id*="email"], input[placeholder*="email" i], input[placeholder*="Email"]' + ); + return input && isVisibleElement(input) ? input : null; } -function buildStep7RestartFromStep6Marker(reason, url = location.href) { - return `STEP7_RESTART_FROM_STEP6::${reason || 'unknown'}::${url || ''}`; +function getLoginPasswordInput() { + const input = document.querySelector('input[type="password"]'); + return input && isVisibleElement(input) ? input : null; } -function getStep7RestartFromStep6Signal() { - const timeoutPage = getLoginTimeoutErrorPageState(); - if (!timeoutPage) { - return null; +function getLoginSubmitButton({ allowDisabled = false } = {}) { + const direct = document.querySelector('button[type="submit"], input[type="submit"]'); + if (direct && isVisibleElement(direct) && (allowDisabled || isActionEnabled(direct))) { + return direct; } - return { - error: buildStep7RestartFromStep6Marker('login_timeout_error_page', timeoutPage.url), - restartFromStep6: true, - reason: 'login_timeout_error_page', - url: timeoutPage.url, + const candidates = document.querySelectorAll( + 'button, a, [role="button"], [role="link"], input[type="button"], input[type="submit"]' + ); + return Array.from(candidates).find((el) => { + if (!isVisibleElement(el) || (!allowDisabled && !isActionEnabled(el))) return false; + const text = getActionText(el); + if (!text || ONE_TIME_CODE_LOGIN_PATTERN.test(text)) return false; + return /continue|next|submit|sign\s*in|log\s*in|继续|下一步|登录/i.test(text); + }) || null; +} + +function inspectLoginAuthState() { + const retryState = getLoginTimeoutErrorPageState(); + const verificationTarget = getVerificationCodeTarget(); + const passwordInput = getLoginPasswordInput(); + const emailInput = getLoginEmailInput(); + const switchTrigger = findOneTimeCodeLoginTrigger(); + const submitButton = getLoginSubmitButton({ allowDisabled: true }); + const verificationVisible = isVerificationPageStillVisible(); + const addPhonePage = isAddPhonePageReady(); + const consentReady = isStep8Ready(); + const oauthConsentPage = isOAuthConsentPage(); + const baseState = { + state: 'unknown', + url: location.href, + path: location.pathname || '', + retryButton: retryState?.retryButton || null, + retryEnabled: Boolean(retryState?.retryEnabled), + titleMatched: Boolean(retryState?.titleMatched), + detailMatched: Boolean(retryState?.detailMatched), + verificationTarget, + passwordInput, + emailInput, + submitButton, + switchTrigger, + verificationVisible, + addPhonePage, + oauthConsentPage, + consentReady, }; + + if (verificationTarget || verificationVisible) { + return { + ...baseState, + state: 'verification_page', + }; + } + + if (retryState) { + return { + ...baseState, + state: 'login_timeout_error_page', + }; + } + + if (addPhonePage) { + return { + ...baseState, + state: 'add_phone_page', + }; + } + + if (oauthConsentPage) { + return { + ...baseState, + state: 'oauth_consent_page', + }; + } + + if (passwordInput || switchTrigger) { + return { + ...baseState, + state: 'password_page', + }; + } + + if (emailInput) { + return { + ...baseState, + state: 'email_page', + }; + } + + return baseState; +} + +function serializeLoginAuthState(snapshot) { + return { + state: snapshot?.state || 'unknown', + url: snapshot?.url || location.href, + path: snapshot?.path || location.pathname || '', + retryEnabled: Boolean(snapshot?.retryEnabled), + titleMatched: Boolean(snapshot?.titleMatched), + detailMatched: Boolean(snapshot?.detailMatched), + hasVerificationTarget: Boolean(snapshot?.verificationTarget), + hasPasswordInput: Boolean(snapshot?.passwordInput), + hasEmailInput: Boolean(snapshot?.emailInput), + hasSubmitButton: Boolean(snapshot?.submitButton), + hasSwitchTrigger: Boolean(snapshot?.switchTrigger), + verificationVisible: Boolean(snapshot?.verificationVisible), + addPhonePage: Boolean(snapshot?.addPhonePage), + oauthConsentPage: Boolean(snapshot?.oauthConsentPage), + consentReady: Boolean(snapshot?.consentReady), + }; +} + +function getLoginAuthStateLabel(snapshot) { + switch (snapshot?.state) { + case 'verification_page': + return '登录验证码页'; + case 'password_page': + return '密码页'; + case 'email_page': + return '邮箱输入页'; + case 'login_timeout_error_page': + return '登录超时报错页'; + case 'oauth_consent_page': + return 'OAuth 授权页'; + case 'add_phone_page': + return '手机号页'; + default: + return '未知页面'; + } +} + +async function waitForKnownLoginAuthState(timeout = 15000) { + const start = Date.now(); + let snapshot = inspectLoginAuthState(); + + while (Date.now() - start < timeout) { + throwIfStopped(); + snapshot = inspectLoginAuthState(); + if (snapshot.state !== 'unknown') { + return snapshot; + } + await sleep(200); + } + + return snapshot; +} + +async function waitForLoginVerificationPageReady(timeout = 10000) { + const start = Date.now(); + let snapshot = inspectLoginAuthState(); + + while (Date.now() - start < timeout) { + throwIfStopped(); + snapshot = inspectLoginAuthState(); + if (snapshot.state === 'verification_page') { + return snapshot; + } + if (snapshot.state !== 'unknown') { + break; + } + await sleep(200); + } + + throw new Error( + `当前未进入登录验证码页面,请先重新完成步骤 6。当前状态:${getLoginAuthStateLabel(snapshot)}。URL: ${snapshot?.url || location.href}` + ); +} + +function createStep6SuccessResult(snapshot, options = {}) { + return { + step6Outcome: 'success', + state: snapshot?.state || 'verification_page', + url: snapshot?.url || location.href, + via: options.via || '', + loginVerificationRequestedAt: options.loginVerificationRequestedAt || null, + }; +} + +function createStep6RecoverableResult(reason, snapshot, options = {}) { + return { + step6Outcome: 'recoverable', + reason, + state: snapshot?.state || 'unknown', + url: snapshot?.url || location.href, + message: options.message || '', + loginVerificationRequestedAt: options.loginVerificationRequestedAt || null, + }; +} + +function throwForStep6FatalState(snapshot) { + switch (snapshot?.state) { + case 'oauth_consent_page': + throw new Error(`当前页面已进入 OAuth 授权页,未经过登录验证码页,无法完成步骤 6。URL: ${snapshot.url}`); + case 'add_phone_page': + throw new Error(`当前页面已进入手机号页面,未经过登录验证码页,无法完成步骤 6。URL: ${snapshot.url}`); + case 'unknown': + throw new Error(`无法识别当前登录页面状态。URL: ${snapshot?.url || location.href}`); + default: + return; + } +} + +async function triggerLoginSubmitAction(button, fallbackField) { + const form = button?.form || fallbackField?.form || button?.closest?.('form') || fallbackField?.closest?.('form') || null; + + await humanPause(400, 1100); + if (button && isActionEnabled(button)) { + simulateClick(button); + return; + } + + if (form && typeof form.requestSubmit === 'function') { + if (button && button.form === form) { + form.requestSubmit(button); + } else { + form.requestSubmit(); + } + return; + } + + if (button && typeof button.click === 'function') { + button.click(); + return; + } + + throw new Error('未找到可用的登录提交按钮。URL: ' + location.href); +} + +function isSignupPasswordErrorPage() { + return Boolean(getSignupPasswordTimeoutErrorPageState()); } function isSignupEmailAlreadyExistsPage() { @@ -922,10 +1067,7 @@ async function fillVerificationCode(step, payload) { log(`步骤 ${step}:正在填写验证码:${code}`); if (step === 7) { - const prepareResult = await prepareLoginCodeFlow(); - if (prepareResult?.restartFromStep6) { - return prepareResult; - } + await waitForLoginVerificationPageReady(); } // Find code input — could be a single input or multiple separate inputs @@ -986,63 +1128,344 @@ async function fillVerificationCode(step, payload) { // Step 6: Login with registered account (on OAuth auth page) // ============================================================ +async function waitForStep6EmailSubmitTransition(emailSubmittedAt, timeout = 12000) { + const start = Date.now(); + let snapshot = inspectLoginAuthState(); + + while (Date.now() - start < timeout) { + throwIfStopped(); + snapshot = inspectLoginAuthState(); + + if (snapshot.state === 'verification_page') { + return { + action: 'done', + result: createStep6SuccessResult(snapshot, { + via: 'email_submit', + loginVerificationRequestedAt: emailSubmittedAt, + }), + }; + } + + if (snapshot.state === 'password_page') { + return { action: 'password', snapshot }; + } + + if (snapshot.state === 'login_timeout_error_page') { + return { + action: 'recoverable', + result: createStep6RecoverableResult('login_timeout_error_page', snapshot, { + message: '提交邮箱后进入登录超时报错页。', + }), + }; + } + + if (snapshot.state === 'oauth_consent_page') { + throw new Error(`提交邮箱后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`); + } + + if (snapshot.state === 'add_phone_page') { + throw new Error(`提交邮箱后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`); + } + + await sleep(250); + } + + snapshot = inspectLoginAuthState(); + if (snapshot.state === 'verification_page') { + return { + action: 'done', + result: createStep6SuccessResult(snapshot, { + via: 'email_submit', + loginVerificationRequestedAt: emailSubmittedAt, + }), + }; + } + if (snapshot.state === 'password_page') { + return { action: 'password', snapshot }; + } + if (snapshot.state === 'login_timeout_error_page') { + return { + action: 'recoverable', + result: createStep6RecoverableResult('login_timeout_error_page', snapshot, { + message: '提交邮箱后进入登录超时报错页。', + }), + }; + } + if (snapshot.state === 'oauth_consent_page') { + throw new Error(`提交邮箱后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`); + } + if (snapshot.state === 'add_phone_page') { + throw new Error(`提交邮箱后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`); + } + + return { + action: 'recoverable', + result: createStep6RecoverableResult('email_submit_stalled', snapshot, { + message: '提交邮箱后长时间未进入密码页或登录验证码页。', + }), + }; +} + +async function waitForStep6PasswordSubmitTransition(passwordSubmittedAt, timeout = 10000) { + const start = Date.now(); + let snapshot = inspectLoginAuthState(); + + while (Date.now() - start < timeout) { + throwIfStopped(); + snapshot = inspectLoginAuthState(); + + if (snapshot.state === 'verification_page') { + return { + action: 'done', + result: createStep6SuccessResult(snapshot, { + via: 'password_submit', + loginVerificationRequestedAt: passwordSubmittedAt, + }), + }; + } + + if (snapshot.state === 'login_timeout_error_page') { + return { + action: 'recoverable', + result: createStep6RecoverableResult('login_timeout_error_page', snapshot, { + message: '提交密码后进入登录超时报错页。', + }), + }; + } + + if (snapshot.state === 'oauth_consent_page') { + throw new Error(`提交密码后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`); + } + + if (snapshot.state === 'add_phone_page') { + throw new Error(`提交密码后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`); + } + + await sleep(250); + } + + snapshot = inspectLoginAuthState(); + if (snapshot.state === 'verification_page') { + return { + action: 'done', + result: createStep6SuccessResult(snapshot, { + via: 'password_submit', + loginVerificationRequestedAt: passwordSubmittedAt, + }), + }; + } + if (snapshot.state === 'login_timeout_error_page') { + return { + action: 'recoverable', + result: createStep6RecoverableResult('login_timeout_error_page', snapshot, { + message: '提交密码后进入登录超时报错页。', + }), + }; + } + if (snapshot.state === 'oauth_consent_page') { + throw new Error(`提交密码后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`); + } + if (snapshot.state === 'add_phone_page') { + throw new Error(`提交密码后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`); + } + if (snapshot.state === 'password_page' && snapshot.switchTrigger) { + return { action: 'switch', snapshot }; + } + + return { + action: 'recoverable', + result: createStep6RecoverableResult('password_submit_stalled', snapshot, { + message: '提交密码后仍未进入登录验证码页。', + }), + }; +} + +async function waitForStep6SwitchTransition(loginVerificationRequestedAt, timeout = 10000) { + const start = Date.now(); + let snapshot = inspectLoginAuthState(); + + while (Date.now() - start < timeout) { + throwIfStopped(); + snapshot = inspectLoginAuthState(); + + if (snapshot.state === 'verification_page') { + return createStep6SuccessResult(snapshot, { + via: 'switch_to_one_time_code_login', + loginVerificationRequestedAt, + }); + } + + if (snapshot.state === 'login_timeout_error_page') { + return createStep6RecoverableResult('login_timeout_error_page', snapshot, { + message: '切换到一次性验证码登录后进入登录超时报错页。', + }); + } + + if (snapshot.state === 'oauth_consent_page') { + throw new Error(`切换到一次性验证码登录后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`); + } + + if (snapshot.state === 'add_phone_page') { + throw new Error(`切换到一次性验证码登录后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`); + } + + await sleep(250); + } + + snapshot = inspectLoginAuthState(); + if (snapshot.state === 'verification_page') { + return createStep6SuccessResult(snapshot, { + via: 'switch_to_one_time_code_login', + loginVerificationRequestedAt, + }); + } + if (snapshot.state === 'login_timeout_error_page') { + return createStep6RecoverableResult('login_timeout_error_page', snapshot, { + message: '切换到一次性验证码登录后进入登录超时报错页。', + }); + } + if (snapshot.state === 'oauth_consent_page') { + throw new Error(`切换到一次性验证码登录后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`); + } + if (snapshot.state === 'add_phone_page') { + throw new Error(`切换到一次性验证码登录后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`); + } + + return createStep6RecoverableResult('one_time_code_switch_stalled', snapshot, { + message: '点击一次性验证码登录后仍未进入登录验证码页。', + }); +} + +async function step6SwitchToOneTimeCodeLogin(snapshot) { + const switchTrigger = snapshot?.switchTrigger || findOneTimeCodeLoginTrigger(); + if (!switchTrigger || !isActionEnabled(switchTrigger)) { + return createStep6RecoverableResult('missing_one_time_code_trigger', inspectLoginAuthState(), { + message: '当前登录页没有可用的一次性验证码登录入口。', + }); + } + + log('步骤 6:已检测到一次性验证码登录入口,准备切换...'); + const loginVerificationRequestedAt = Date.now(); + await humanPause(350, 900); + simulateClick(switchTrigger); + log('步骤 6:已点击一次性验证码登录'); + await sleep(1200); + return waitForStep6SwitchTransition(loginVerificationRequestedAt); +} + +async function step6LoginFromPasswordPage(payload, snapshot) { + const currentSnapshot = snapshot || inspectLoginAuthState(); + + if (currentSnapshot.passwordInput) { + if (!payload.password) { + throw new Error('登录时缺少密码,步骤 6 无法继续。'); + } + + log('步骤 6:已进入密码页,准备填写密码...'); + await humanPause(550, 1450); + fillInput(currentSnapshot.passwordInput, payload.password); + log('步骤 6:已填写密码'); + + await sleep(500); + const passwordSubmittedAt = Date.now(); + await triggerLoginSubmitAction(currentSnapshot.submitButton, currentSnapshot.passwordInput); + log('步骤 6:已提交密码'); + + const transition = await waitForStep6PasswordSubmitTransition(passwordSubmittedAt); + if (transition.action === 'done') { + log('步骤 6:已进入登录验证码页面。', 'ok'); + return transition.result; + } + if (transition.action === 'recoverable') { + log(`步骤 6:${transition.result.message || '提交密码后仍未进入登录验证码页面,准备重新执行步骤 6。'}`, 'warn'); + return transition.result; + } + if (transition.action === 'switch') { + return step6SwitchToOneTimeCodeLogin(transition.snapshot); + } + + return createStep6RecoverableResult('password_submit_unknown', inspectLoginAuthState(), { + message: '提交密码后未得到可用的下一步状态。', + }); + } + + if (currentSnapshot.switchTrigger) { + return step6SwitchToOneTimeCodeLogin(currentSnapshot); + } + + return createStep6RecoverableResult('password_page_unactionable', currentSnapshot, { + message: '当前停留在登录页,但没有可提交密码的输入框,也没有一次性验证码登录入口。', + }); +} + +async function step6LoginFromEmailPage(payload, snapshot) { + const currentSnapshot = snapshot || inspectLoginAuthState(); + const emailInput = currentSnapshot.emailInput || getLoginEmailInput(); + if (!emailInput) { + throw new Error('在登录页未找到邮箱输入框。URL: ' + location.href); + } + + if ((emailInput.value || '').trim() !== payload.email) { + await humanPause(500, 1400); + fillInput(emailInput, payload.email); + log('步骤 6:已填写邮箱'); + } else { + log('步骤 6:邮箱已在输入框中,准备提交...'); + } + + await sleep(500); + const emailSubmittedAt = Date.now(); + await triggerLoginSubmitAction(currentSnapshot.submitButton, emailInput); + log('步骤 6:已提交邮箱'); + + const transition = await waitForStep6EmailSubmitTransition(emailSubmittedAt); + if (transition.action === 'done') { + log('步骤 6:已进入登录验证码页面。', 'ok'); + return transition.result; + } + if (transition.action === 'recoverable') { + log(`步骤 6:${transition.result.message || '提交邮箱后仍未进入目标页面,准备重新执行步骤 6。'}`, 'warn'); + return transition.result; + } + if (transition.action === 'password') { + return step6LoginFromPasswordPage(payload, transition.snapshot); + } + + return createStep6RecoverableResult('email_submit_unknown', inspectLoginAuthState(), { + message: '提交邮箱后未得到可用的下一步状态。', + }); +} + async function step6_login(payload) { - const { email, password } = payload; + const { email } = payload; if (!email) throw new Error('登录时缺少邮箱地址。'); log(`步骤 6:正在使用 ${email} 登录...`); - // Wait for email input on the auth page - let emailInput = null; - try { - emailInput = await waitForElement( - 'input[type="email"], input[name="email"], input[name="username"], input[id*="email"], input[placeholder*="email" i], input[placeholder*="Email"]', - 15000 - ); - } catch { - throw new Error('在登录页未找到邮箱输入框。URL: ' + location.href); + const snapshot = await waitForKnownLoginAuthState(15000); + + if (snapshot.state === 'verification_page') { + log('步骤 6:登录验证码页面已就绪。', 'ok'); + return createStep6SuccessResult(snapshot, { via: 'already_on_verification_page' }); } - await humanPause(500, 1400); - fillInput(emailInput, email); - log('步骤 6:邮箱已填写'); - - // Submit email - await sleep(500); - const submitBtn1 = document.querySelector('button[type="submit"]') - || await waitForElementByText('button', /continue|next|submit|继续|下一步/i, 5000).catch(() => null); - if (submitBtn1) { - await humanPause(400, 1100); - simulateClick(submitBtn1); - log('步骤 6:邮箱已提交'); + if (snapshot.state === 'login_timeout_error_page') { + log('步骤 6:检测到登录超时报错,准备重新执行步骤 6。', 'warn'); + return createStep6RecoverableResult('login_timeout_error_page', snapshot, { + message: '当前页面处于登录超时报错页。', + }); } - await sleep(2000); - - // Check for password field - const passwordInput = document.querySelector('input[type="password"]'); - if (passwordInput) { - log('步骤 6:已找到密码输入框,正在填写密码...'); - await humanPause(550, 1450); - fillInput(passwordInput, password); - - await sleep(500); - const submitBtn2 = document.querySelector('button[type="submit"]') - || await waitForElementByText('button', /continue|log\s*in|submit|sign\s*in|登录|继续/i, 5000).catch(() => null); - // Report complete BEFORE submit in case page navigates - reportComplete(6, { needsOTP: true }); - - if (submitBtn2) { - await humanPause(450, 1200); - simulateClick(submitBtn2); - log('步骤 6:密码已提交,可能还需要验证码(步骤 7)'); - } - return; + if (snapshot.state === 'email_page') { + return step6LoginFromEmailPage(payload, snapshot); } - // No password field — OTP flow - log('步骤 6:未发现密码输入框,可能进入验证码流程或自动跳转。'); - reportComplete(6, { needsOTP: true }); + if (snapshot.state === 'password_page') { + return step6LoginFromPasswordPage(payload, snapshot); + } + + throwForStep6FatalState(snapshot); + throw new Error(`无法识别当前登录页面状态。URL: ${snapshot?.url || location.href}`); } // ============================================================ diff --git a/content/vps-panel.js b/content/vps-panel.js index f3d44e3..df1ef4b 100644 --- a/content/vps-panel.js +++ b/content/vps-panel.js @@ -603,25 +603,26 @@ async function step9_vpsVerify(payload) { fillInput(urlInput, localhostUrl); log(`步骤 9:已填写回调地址:${localhostUrl.slice(0, 80)}...`); - // Find and click "提交回调 URL" button + // Find and click the callback submit button in supported UI languages. + const callbackSubmitPattern = /提交回调\s*URL|Submit\s+Callback\s+URL|Отправить\s+Callback\s+URL/i; let submitBtn = null; try { submitBtn = await waitForElementByText( '[class*="callbackActions"] button, [class*="callbackSection"] button', - /提交/, + callbackSubmitPattern, 5000 ); } catch { try { - submitBtn = await waitForElementByText('button.btn', /提交回调/, 5000); + submitBtn = await waitForElementByText('button.btn', callbackSubmitPattern, 5000); } catch { - throw new Error('未找到“提交回调 URL”按钮。URL: ' + location.href); + throw new Error('未找到回调提交按钮(提交回调 URL / Submit Callback URL / Отправить Callback URL)。URL: ' + location.href); } } await humanPause(450, 1200); simulateClick(submitBtn); - log('步骤 9:已点击“提交回调 URL”,正在等待认证结果...'); + log('步骤 9:已点击回调提交按钮,正在等待认证结果...'); const verifiedStatus = await waitForExactSuccessBadge(); log(`步骤 9:${verifiedStatus}`, 'ok'); diff --git a/docs/AI版本发布标准流程.md b/docs/AI版本发布标准流程.md deleted file mode 100644 index 13d0e9a..0000000 --- a/docs/AI版本发布标准流程.md +++ /dev/null @@ -1,400 +0,0 @@ -# AI 版本发布标准流程 - -## 用途 - -本文用于给 AI 一个固定、完整、可重复执行的版本发布流程。 - -当用户在本仓库中引用本文,并明确说出类似下面的话时: - -- `更新版本到 6.5.0` -- `按本文发布 6.5.0` -- `引用这个文件,把版本更新到 6.5.0 并发版` - -AI 必须按本文执行,不能猜流程,不能跳步骤,不能只做一半。 - -## 目标 - -AI 在收到“更新版本到 x.x.x”后,必须完成下面两段流程。 - -1. 发布前流程。 -修改 `manifest.json` 中的版本号。 -提交本次准备发布的代码。 -基于“上一个 GitHub Release 到当前 HEAD”的真实差异分析更新内容。 -把发布文案先返回给用户看,且必须放在代码块里。 - -2. 用户确认后的发布流程。 -把用户确认过的文案作为 GitHub Release 正文发布。 -发布完成后,再次从 GitHub 线上读取正文。 -审查正文是否乱码、是否异常、是否与用户确认稿一致。 -如果有问题,必须立刻修正后再汇报完成。 - -## 仓库硬性规则 - -1. 任何结论都不能猜,必须基于真实命令输出、真实 diff、真实 GitHub 返回结果。 -2. GitHub Releases 是版本发布真相源。分析“距离上一个版本更新了什么”时,默认以上一个正式 Release 为起点。 -3. 不要先去检查 `gh` 是否存在、是否登录、是否在 PATH、是否可用,也不要先跑 `gh auth status`。直接使用固定路径命令执行。 -4. `gh` 固定路径使用: - -```powershell -$GH = 'C:\Program Files\GitHub CLI\gh.exe' -if (-not (Test-Path $GH)) { - throw 'gh.exe fixed path not found: C:\Program Files\GitHub CLI\gh.exe' -} -``` - -5. 默认 GitHub 仓库固定为: - -```powershell -$Repo = 'QLHazyCoder/codex-oauth-automation-extension' -``` - -6. 默认不编译、不跑测试。完成后提醒用户自行测试。 -7. 如果当前工作区里存在无法确认是否属于本次发布的改动,必须先停下来问用户,不能猜哪些该一起发、哪些不该一起发。 -8. 发布完成后,必须做一次“线上正文复检”,确认不是乱码,不是空正文,不是错误版本,不是错误标签。 - -## 输入要求 - -AI 至少要从用户输入中拿到下面这些信息: - -- 目标版本号,例如 `6.5.0` -- 是否已经确认可以发布 -- 如果用户有特别强调的更新点,也要纳入发布文案 - -如果用户没有给版本号,就不能猜。 - -## 固定变量模板 - -每次执行本文时,先统一使用下面这组变量: - -```powershell -$GH = 'C:\Program Files\GitHub CLI\gh.exe' -if (-not (Test-Path $GH)) { - throw 'gh.exe fixed path not found: C:\Program Files\GitHub CLI\gh.exe' -} - -$Repo = 'QLHazyCoder/codex-oauth-automation-extension' -$TargetVersion = '<用户指定的版本号,但是必须按照版本标准写,v+x.x.x。比如: v6.5.0>' -$TargetTag = "v$TargetVersion" -$ManifestPath = 'manifest.json' -``` - -## 阶段 1:发布前检查 - -### 1. 真实查看当前工作区 - -先执行: - -```powershell -git status --short --branch -git remote -v -``` - -要求: - -1. 必须看清当前分支和当前未提交改动。 -2. 如果工作区中有改动,必须结合 diff 判断这些改动是不是本次要发布的真实内容。 -3. 如果有无法确认归属的改动,必须先问用户,不准猜。 - -### 2. 读取当前版本号 - -执行: - -```powershell -$Manifest = Get-Content -Path $ManifestPath -Raw | ConvertFrom-Json -$CurrentVersion = [string]$Manifest.version -$CurrentVersion -``` - -要求: - -1. 当前版本必须从 `manifest.json` 真实读取。 -2. 目标版本必须大于当前版本,不能相等,不能更小。 -3. 版本比较必须按版本号比较,不能按字符串比较。 - -示例校验: - -```powershell -if ([version]$TargetVersion -le [version]$CurrentVersion) { - throw "Target version $TargetVersion must be greater than current version $CurrentVersion" -} -``` - -### 3. 检查目标版本是否已经存在 - -先查 GitHub Releases: - -```powershell -$ExistingReleaseTag = ([string](& $GH api "repos/$Repo/releases?per_page=100" --jq ".[] | select(.tag_name == `"$TargetTag`") | .tag_name")).Trim() -``` - -再查本地 tag: - -```powershell -$ExistingLocalTag = ([string](git tag --list $TargetTag)).Trim() -``` - -再查最新一个正式 Release: - -```powershell -$LatestReleaseTag = ([string](& $GH api "repos/$Repo/releases?per_page=100" --jq "[.[] | select(.draft == false and .prerelease == false)][0].tag_name")).Trim() -$LatestReleaseVersion = if ($LatestReleaseTag) { $LatestReleaseTag.TrimStart('v') } else { '' } -``` - -规则: - -1. 只要 GitHub Release 已存在这个 tag,就必须停止,不能重复发版。 -2. 如果本地 tag 已存在,但 GitHub Release 不存在,也必须停止并告知用户先确认历史状态,不能直接覆盖。 -3. 如果已经存在最新正式 Release,目标版本还必须大于这个正式 Release 的版本号,不能倒发版。 - -示例校验: - -```powershell -if ($LatestReleaseVersion -and ([version]$TargetVersion -le [version]$LatestReleaseVersion)) { - throw "Target version $TargetVersion must be greater than latest released version $LatestReleaseVersion" -} -``` - -## 阶段 2:更新版本号并提交 - -### 1. 修改 `manifest.json` - -AI 必须只把 `manifest.json` 中的 `version` 更新为目标版本。 - -### 2. 检查变更内容 - -执行: - -```powershell -git diff --stat -git diff -- manifest.json -``` - -如果工作区里还有其他变更,也要继续真实查看它们的 diff,确认是否属于本次发布内容。 - -### 3. 提交本次发布 - -规则: - -1. 如果当前工作区改动就是本次要发布的功能改动,就应与版本号一起提交。 -2. 如果存在来源不明或明显无关的改动,必须先停下来问用户,不能偷偷一起提交,也不能擅自丢掉。 -3. 提交信息统一使用: - -```text -chore(release): bump version to vX.Y.Z -``` - -示例: - -```powershell -git add -A -git commit -m "chore(release): bump version to $TargetTag" -``` - -提交后,必须记录真实提交哈希: - -```powershell -git rev-parse HEAD -``` - -## 阶段 3:分析“距离上一个版本更新了什么” - -### 1. 获取上一个正式 Release - -执行: - -```powershell -$PreviousReleaseTag = ([string](& $GH api "repos/$Repo/releases?per_page=100" --jq "[.[] | select(.draft == false and .prerelease == false)][0].tag_name")).Trim() -$PreviousReleaseTag -``` - -规则: - -1. 默认取最新一个正式 Release 作为“上一个版本”。 -2. 如果仓库还没有任何正式 Release,再退回到本地最新 tag: - -```powershell -if (-not $PreviousReleaseTag) { - $PreviousReleaseTag = ([string](git tag --sort=-creatordate | Select-Object -First 1)).Trim() -} -``` - -3. 如果仍然拿不到上一个版本,就按“首个版本发布”处理,并明确告诉用户这是首发版文案。 - -### 2. 拉真实变更范围 - -如果存在上一个版本,执行: - -```powershell -git log --oneline "$PreviousReleaseTag"..HEAD -git diff --stat "$PreviousReleaseTag"..HEAD -git diff --name-only "$PreviousReleaseTag"..HEAD -``` - -然后根据变更文件继续往下读真实 diff,不能只看 commit 标题。 - -必要时继续执行: - -```powershell -git diff "$PreviousReleaseTag"..HEAD -- <具体文件路径> -``` - -### 3. 文案分析要求 - -AI 必须基于真实 diff 和真实代码上下文,分析并提炼: - -- 新增了什么功能 -- 修复了什么问题 -- 优化了什么逻辑 -- 删除了什么无用或陈旧逻辑 -- 哪些点最值得放进发布说明 - -禁止行为: - -- 只看 commit message 就编文案 -- 把没有真实证据的内容写进发布说明 -- 把技术细节胡乱拔高成“重大更新” - -## 阶段 4:先给用户看发布文案,不要直接发布 - -在真正发布前,AI 必须把文案返回给用户确认。 - -返回给用户时,必须同时说明: - -1. `manifest.json` 已改到哪个版本 -2. 是否已经提交 -3. 本次提交哈希是什么 -4. 上一个版本标签是什么 -5. 发布文案草稿是什么 - -发布文案草稿必须放在代码块中,格式建议如下: - -```markdown -## vX.Y.Z - -### 更新内容 -- ... -- ... - -### 修复与优化 -- ... -- ... -``` - -注意: - -1. 这里必须先停下来等用户确认。 -2. 用户没确认前,不准创建 GitHub Release。 -3. 如果用户要求改文案,只改文案,不要乱改已经完成的代码与提交,除非用户明确要求。 - -## 阶段 5:用户确认后再发布 GitHub Release - -### 1. 先推送当前提交 - -发布前先把当前分支 HEAD 推到远端: - -```powershell -git push origin HEAD -``` - -### 2. 把用户确认过的文案写入 UTF-8 文件 - -不要直接把长正文硬塞进命令参数里,先写入临时文件。 - -示例: - -```powershell -$ReleaseNotesFile = Join-Path $env:TEMP "codex-release-$TargetVersion.md" -$Utf8NoBom = New-Object System.Text.UTF8Encoding($false) -[System.IO.File]::WriteAllText($ReleaseNotesFile, $ReleaseNotes, $Utf8NoBom) -``` - -### 3. 创建 Release - -执行: - -```powershell -& $GH release create $TargetTag ` - --repo $Repo ` - --target HEAD ` - --title $TargetTag ` - --notes-file $ReleaseNotesFile -``` - -要求: - -1. 标题默认使用 `vX.Y.Z`。 -2. 正文必须使用用户确认过的最终文案。 -3. 不要在用户没确认前抢先发版。 - -## 阶段 6:发布后线上正文复检 - -Release 创建完成后,必须立刻重新读取线上内容。 - -执行: - -```powershell -& $GH api "repos/$Repo/releases/tags/$TargetTag" -& $GH api "repos/$Repo/releases/tags/$TargetTag" --jq '.html_url' -& $GH api "repos/$Repo/releases/tags/$TargetTag" --jq '.tag_name' -& $GH api "repos/$Repo/releases/tags/$TargetTag" --jq '.name' -& $GH api "repos/$Repo/releases/tags/$TargetTag" --jq '.body' -``` - -复检重点: - -1. 线上 tag 是否真的是目标 tag -2. 线上标题是否真的是目标版本 -3. 线上正文是否完整 -4. 正文是否与用户确认稿一致 -5. 是否存在乱码、异常字符、错误换行、空正文 - -尤其注意下面这些异常: - -- 出现明显乱码字符 -- 中文被破坏 -- 正文为空 -- 版本号不对 -- 正文内容被截断 - -### 如果复检发现异常 - -必须立刻修复,不准带病汇报完成。 - -修复示例: - -```powershell -& $GH release edit $TargetTag ` - --repo $Repo ` - --title $TargetTag ` - --notes-file $ReleaseNotesFile -``` - -修复后,再重复执行一次“发布后线上正文复检”,直到线上内容正常为止。 - -## 用户侧最终反馈要求 - -AI 完成后,对用户的最终反馈至少要说明: - -1. 已把 `manifest.json` 更新到哪个版本 -2. 是否已经提交,提交哈希是什么 -3. 上一个版本是什么 -4. 发布文案是否经过用户确认 -5. 是否已经创建 GitHub Release -6. 是否已经完成线上正文复检 -7. 如果没跑测试,要明确提醒用户自行测试 - -## 一句话执行要求 - -当用户引用本文并说“更新版本到 x.x.x”时,AI 必须按下面顺序执行: - -1. 真实检查工作区与当前版本 -2. 真实校验目标版本是否合法且未发布 -3. 修改 `manifest.json` 版本号 -4. 提交本次待发布改动 -5. 基于上一个正式 Release 到当前 HEAD 的真实差异分析更新内容 -6. 把发布文案放进代码块返回给用户确认 -7. 得到确认后再推送并创建 GitHub Release -8. 发布完成后再次读取线上正文并检查是否乱码 -9. 如有异常,先修复再汇报 - -不能跳步骤,不能猜,不能偷懒。 diff --git a/Hotmail双模式适配开发方案及开发记录.md b/docs/Hotmail双模式适配开发方案及开发记录.md similarity index 100% rename from Hotmail双模式适配开发方案及开发记录.md rename to docs/Hotmail双模式适配开发方案及开发记录.md diff --git a/docs/images/交流群.jpg b/docs/images/交流群.jpg index a82eefd..33042a8 100644 Binary files a/docs/images/交流群.jpg and b/docs/images/交流群.jpg differ diff --git a/docs/仓库协作者AI分析PR与合并标准流程.md b/docs/仓库协作者AI分析PR与合并标准流程.md new file mode 100644 index 0000000..9bc4b48 --- /dev/null +++ b/docs/仓库协作者AI分析PR与合并标准流程.md @@ -0,0 +1,338 @@ +# 仓库协作者用 AI 分析 PR 与合并标准流程 + +## 用途 + +本文件用于给仓库协作者自己电脑上的 AI 一个固定、完整、可重复执行的 PR 处理流程。 + +下次只要告诉 AI: + +- 本仓库根目录 +- 要处理的 PR 编号 +- 是否只分析,还是分析后需要继续合并 + +再把本文件提供给 AI,AI 就必须按本文件执行,不能自行脑补流程。 + +## 仓库硬性规则 + +1. 任何结论都不能猜,必须基于真实命令输出、真实 diff、真实代码上下文。 +2. 优先使用当前环境可直接执行的 `gh`、`git` 命令;如果命令不存在、认证失败、权限不足,必须立刻明确告知用户并停止,不能假装已经操作成功。 +3. 本仓库自动处理的唯一目标分支是 `dev`。AI 不得把其他作者的 PR 直接作为自动流程合并到 `master`。 +4. 任何分支判断都必须以实时 `gh pr view` 返回的 `baseRefName` 为准,不能只信用户口述、PR 标题或截图描述。 +5. 如果 PR 当前目标分支是 `master`,AI 必须先自动把它改到 `dev`,然后重新拉取 PR 元数据与 diff,再继续分析。 +6. 如果 PR 当前目标分支既不是 `dev` 也不是 `master`,AI 不得擅自改到别的分支,必须停止并告诉用户当前自动流程只支持 `dev`。 +7. 如果把 `master` 转到 `dev` 时发现同一个来源仓库 + 来源分支已经存在另一个指向 `dev` 的打开 PR,AI 不得继续硬转,不得继续自动合并,必须先反馈重复 PR 问题。 +8. 用户当前工作区可能是脏的,不能直接在当前工作区切分支、切换分支、硬合并。需要使用临时 `worktree`。 +9. 如果合并后需要本地修复,优先删除无用旧逻辑,避免继续堆积陈旧代码。 +10. 默认不编译测试。处理完成后提醒用户自行测试。 +11. 任何 GitHub 评论、回复、感谢留言发出后,AI 都必须立即再读一遍线上实际内容,确认正文不是乱码、不是 `?`、不是编码异常;如果发现异常,必须立刻修正后再继续后续流程。 +12. 如果流程执行过程中,PR 的实时目标分支被别人改掉,或者不再是 `dev`,AI 必须停止当前合并流程,重新拉取信息后再决定下一步。 +13. 进入“阶段 2:分析与审查”时,AI 必须先给当前 PR 添加 `审查中` 标签,并在开始正式审查前确认该标签已经在线上生效。 + +## 输入要求 + +AI 至少要拿到下面这些输入: + +- `PR 编号` +- `仓库路径` +- 本次目标:`只分析` / `分析后需要继续合并` +- 如果需要继续合并:是否允许 AI 直接本地修复冲突和问题 + +补充要求: + +- 如果 AI 不能从当前仓库自动识别 `OWNER/REPO`,则用户还需要补充提供。 +- 本流程默认允许 AI 在检测到 `master` 为目标分支时,直接远程改成 `dev`;如果用户明确禁止任何远程修改,则本流程不适用。 + +## 总流程 + +### 阶段 1:准备、目标分支校正与信息拉取 + +AI 必须先执行下面这些动作,不能跳步: + +1. 检查当前仓库状态: + - `git status --short --branch` + - `git remote -v` +2. 拉取 PR 元数据: + +```powershell +gh pr view --repo --json number,title,body,author,baseRefName,headRefName,headRepository,headRepositoryOwner,changedFiles,additions,deletions,commits,files,labels,isDraft,mergeStateStatus,mergeable,state,url +``` + +3. 先根据实时 `baseRefName` 处理目标分支: + - 如果 `baseRefName = dev`:继续下一步。 + - 如果 `baseRefName = master`:先执行“自动转到 `dev`”流程,再继续下一步。 + - 如果 `baseRefName` 既不是 `dev` 也不是 `master`:立即停止,并向用户反馈“当前自动流程只支持 `dev`”。 + +4. 当 `baseRefName = master` 时,必须执行下面的自动转分支流程: + +```powershell +gh api --method GET repos//pulls -f state=open -f head=':' -f base='dev' -f per_page='100' +``` + +处理规则: + +1. 先从上一步 PR 元数据中读取真实的 `HEAD_OWNER` 与 `HEAD_REF`,不能猜。 +2. 如果查询结果中已经存在 `number != ` 的打开 PR,则视为“重复的 dev PR”: + - 不再执行转分支 + - 不再继续自动分析旧 diff + - 先反馈给当前用户,必要时再给 PR 留言说明 +3. 如果不存在重复的 dev PR,则执行: + +```powershell +gh pr edit --base dev --repo +``` + +4. 改完目标分支后,必须重新执行一次 PR 元数据拉取,确认新的 `baseRefName = dev`。 +5. 在重新确认 `baseRefName = dev` 之前,不允许继续分析旧 diff,不允许继续合并。 + +5. 只有在目标分支已经确认是 `dev` 后,才能拉取 PR diff: + +```powershell +gh pr diff --repo +``` + +6. 拉取目标分支与 PR 头: + +```powershell +git fetch origin dev +git fetch origin pull//head:refs/remotes/origin/pr- +``` + +7. 如果需要稳定查看行号、文件内容、避免污染当前工作区,创建临时 `worktree`: + +```powershell +$TempWorktree = '<临时目录>' +git worktree add --detach $TempWorktree origin/pr- +``` + +### 阶段 2:分析与审查 + +AI 分析时,必须完整执行下面这些要求: + +1. 审查一开始必须先执行: + +```powershell +gh pr edit --add-label "审查中" --repo +``` + +补充要求: + + - 打标后必须立刻重新读取一次实时 PR 元数据,确认 `labels` 中已经出现 `审查中`。 + - 如果仓库里没有 `审查中` 标签、当前账号无权限打标,或者命令执行失败,必须立刻告知用户,不能假装已经开始审查。 +2. 不能只看 PR 描述,必须结合真实 diff 和仓库现有代码上下文一起分析。 +3. 如果阶段 1 发生过 `master -> dev` 自动转向,分析时只能基于转向后的最新 diff,不得继续引用转向前的 diff 结论。 +4. 对于高风险文件,必须继续读取改动函数周边逻辑,确认消息流、状态流、配置项、回调流程、页面跳转流程是否一致。 +5. 必须分析并输出: + - 这个 PR 改了什么 + - 这些改动是否合理 + - 是否存在 bug / 风险 / 逻辑冲突 + - PR 当前是否可直接合并,例如 `mergeable`、`mergeStateStatus` +6. 如果发现问题,结论必须按严重级别排序,优先写真正会影响功能、合并或后续维护的问题。 +7. 如果没有发现明确问题,也要说明剩余风险,例如: + - 未运行测试 + - 需要人工验证真实业务接口 + - 当前仅完成静态分析 +8. 在准备进入后续合并步骤前,必须再拉取一次实时 PR 元数据,确认: + - PR 仍然是打开状态 + - PR 不是 draft + - `baseRefName` 仍然是 `dev` + +## 分析后评论规则 + +### 有问题 / 审核不通过时 + +如果发现需要作者或维护者注意的问题,AI 必须自动发 PR 评论,并且评论格式必须固定如下: + +```text +(自动回复)AI分析结果(不一定完全正确,请仓库协作者确认无问题后再继续): + +<这里写正式评论内容> +``` + +要求: + +1. 第一行必须完全使用上面的标题,不能改字。 +2. 标题下方必须空一行,再写正文。 +3. 正文内容要直接写问题,不要再套娃解释“下面是分析结果”。 +4. 评论内容必须基于实际检查到的问题,不能编。 +5. 评论发出后,必须立刻回读该评论的线上正文,确认不是乱码;如果有乱码,必须先修正评论,再继续后续动作。 +6. 如果问题能够明确定位到某个改动文件、代码块或行号,AI 应优先在对应文件的代码 diff / 代码附件上追加行级评论,直接指出问题和期望修改方式。 +7. 如果结论是“当前不能通过审查”,AI 必须明确写出“需要修改后再继续”,不能只写模糊提醒。 +8. 如果当前环境支持正式 PR review,且问题足以阻止合并,优先使用带“要求更改(Request changes)”语义的审查,而不是只留普通闲聊式评论。 +9. 行级评论是对总评论的补充,不得用零散行级评论替代总评论结论。 + +### 没问题时 + +如果没有发现明确问题: + +1. 先把分析结果反馈给当前用户。 +2. 不强制给 PR 留问题评论。 +3. 是否继续合并,取决于用户命令。 + +## 带问题继续合并的规则 + +如果 PR 有问题,但用户明确要求: + +- 不等待 PR 作者修复 +- 由当前 AI 直接修改 PR 分支代码,处理冲突和问题 +- 处理完成后继续合并 + +则必须进入下面的流程。 + +### 阶段 3:临时工作区修复 PR 分支 + +禁止在用户当前工作区直接乱合并。 + +必须先创建临时 `worktree`,再在临时目录内处理 PR 分支: + +```powershell +git fetch origin dev +git fetch origin pull//head:refs/remotes/origin/pr- +git worktree add -b pr--fix origin/pr- +``` + +进入临时目录后,先把最新 `dev` 合到 PR 分支里,显式暴露冲突: + +```powershell +git merge --no-ff --no-commit origin/dev +``` + +处理规则: + +1. 如果阶段 1 发生过 `master -> dev` 自动转向,本阶段必须以 `dev` 为唯一合并基准,不允许再回到 `master` 做本地吸收。 +2. 如果出现冲突,先在 PR 分支上解决冲突,再继续检查相关联逻辑。 +3. 不能只消掉冲突标记就结束,必须继续看是否有设计冲突、状态字段不一致、调用链断裂、配置项名不一致、回调逻辑互相打架的问题。 +4. 如果 PR 本身逻辑有 bug,而用户又明确要求继续合并,AI 需要直接修改 PR 分支代码并修正。 +5. 修正完成后,必须把修改提交回 PR 分支并推送到远端;如果没有权限推送到 PR 来源分支,必须立刻停止并明确告知用户,不能假装 PR 已修好。 +6. 推送完成后,必须重新拉取一次实时 PR 元数据与最新 diff,确认线上 PR 已包含本次修复,再决定是否继续合并。 +7. 修正时要清理无用旧代码,避免留下史山。 +8. 如果改了 SQL,按仓库规则同步本地 MySQL `xzs`。 + +### 阶段 4:PR 修复推送后的自检清单 + +完成 PR 分支修复并推送后,必须自检下面这些项目: + +1. `git status` 中不能再有未处理冲突。 +2. 相关文件中不能残留冲突标记。 +3. 新旧逻辑之间不能出现字段名、消息名、步骤编号、状态名不一致。 +4. 需要检查与本次功能直接相关的上下游代码,不能只改当前冲突文件。 +5. 要重新查看最终线上 diff,确认修复后的 PR 没有引入明显回归。 +6. 要重新拉取一次实时 PR 元数据,确认该 PR 的 `baseRefName` 仍然是 `dev`;如果不是,停止后续合并并先反馈用户。 +7. 要确认 PR 当前已经回到可继续处理的状态,例如不再是 `draft`,且 `mergeable` / `mergeStateStatus` 没有出现新的阻塞。 +8. 默认不编译测试,最后提醒用户自行测试。 + +## 合并提交信息规则 + +如果最后决定真正合并,提交信息不能直接写: + +- `merge branch xxx` +- `merge pr #19` +- `合并某某分支` + +必须重新分析“最终合并结果到底做了什么”,然后重写提交信息。 + +这些规则同样适用于最终执行 `gh pr merge` 时使用的标题与正文。 + +### 提交信息要求 + +1. 标题必须描述最终落地功能,不是描述 Git 动作。 +2. 标题要体现核心改动结果,而不是“从哪里合并过来”。 +3. 正文要写清楚: + - 这次吸收了 PR 的什么内容 + - 本地额外修复了什么冲突或 bug + - 对哪些关键流程做了调整 + +### 推荐格式 + +```text +: <最终功能或修复结果> + +- 合并 PR # 的核心改动:<一句话概括> +- 本地补充修复:<一句话概括> +- 影响范围:<步骤/模块/页面/接口> +``` + +### 示例 + +```text +feat: support SUB2API mode for OAuth generation and callback handling + +- 合并 PR #19 的核心改动:新增 SUB2API 模式并接入 OAuth 生成与回调提交流程 +- 本地补充修复:修正回调地址约束与超时重试带来的重复执行风险 +- 影响范围:background orchestration、sidepanel config、sub2api content script +``` + +## 最终合并与收尾 + +如果 PR 分支上的冲突 / 问题已经修好,并且复检确认该 PR 可以继续合并到 `dev`,则继续执行下面动作。 + +### 1. 合并 PR + +优先直接合并这个 PR,而不是只在本地偷偷吸收代码后手工关闭: + +```powershell +gh pr merge --merge --subject "" --body "<BODY>" --repo <OWNER/REPO> +``` + +要求: + +1. `--subject` 与 `--body` 必须遵守上面的“合并提交信息规则”。 +2. 合并前必须再次确认 PR 仍然是打开状态、目标分支仍然是 `dev`、线上 diff 已包含你刚刚推送的修复。 +3. 如果合并时发现新的冲突、状态检查阻塞或权限问题,必须停止并把真实阻塞原因反馈给用户。 + +### 2. 感谢作者 + +此时需要自动给 PR 留一条感谢评论。 + +注意: + +1. 感谢评论不要带问题评论的固定标题。 +2. 直接正常感谢即可。 +3. 感谢内容要基于真实贡献,语气简洁。 +4. 如果阶段 1 发生过 `master -> dev` 自动转向,感谢评论不能把最终落地分支写错,必须明确本次内容已吸收到 `dev`。 +5. 感谢评论发出后,也必须立刻回读线上正文,确认不是乱码;如果有乱码,必须先修正评论,再进行关闭 PR 等后续动作。 + +示例: + +```text +感谢贡献这次改动,核心思路和主体实现已经吸收进 dev 分支了。我这边补了一下合并过程里的冲突和相关修正,后续如果你还有类似改进也欢迎继续提交。 +``` + +### 3. 自动关闭 PR + +如果 PR 还没有因为合并而自动关闭,则感谢评论发完后,自动关闭该 PR: + +```powershell +gh pr close <PR_NUMBER> --repo <OWNER/REPO> +``` + +如果需要,先评论再关闭,不要把感谢遗漏掉。 + +### 4. 评论编码复检 + +无论是问题评论、感谢评论,还是其他直接发到 GitHub 的回复,只要消息已经发出,AI 必须执行一次“发送后复检”: + +1. 重新读取该评论/回复在线上的实际正文。 +2. 检查是否存在乱码、异常问号、编码错乱、BOM 污染等问题。 +3. 如果有问题,必须立即编辑修正,直到线上正文正常为止。 +4. 编码复检完成前,不允许声称“评论已发送完成”。 + +## 用户侧最终反馈要求 + +AI 在对当前用户做最终反馈时,至少要说明: + +1. 做了哪些真实动作 +2. PR 原始目标分支是否是 `master` +3. 是否已经执行 `master -> dev` 自动转向 +4. 是否发现重复的 `dev` PR +5. 是否发现问题 +6. 是否已经给 PR 添加 `审查中` 标签 +7. 是否已经发了 PR 评论 / 行级代码评论 +8. 是否已经要求 PR 修改后再继续 +9. 是否已经修改 PR 分支并推回远端 +10. 是否已经完成 PR 合并 +11. 是否已经关闭 PR +12. 如果改了代码但没跑测试,要明确提醒用户测试 + +## 给 AI 的一句话执行要求 + +拿到本文件后,AI 必须按“先真实读取 PR 元数据,先校正目标分支,再拉取最新 diff,在正式审查开始时先给 PR 打上 `审查中` 标签并确认已生效;如果审核不通过,就在对应代码附件上评论并明确要求更改;如果用户要求继续处理冲突和问题,就先修好并推回 PR 分支,再按规则合并 PR,最后感谢作者、关闭 PR”的顺序执行,不能跳步,不能猜,不能偷懒。 diff --git a/icloud-utils.js b/icloud-utils.js new file mode 100644 index 0000000..82c7cc1 --- /dev/null +++ b/icloud-utils.js @@ -0,0 +1,177 @@ +(function icloudUtilsModule(root, factory) { + if (typeof module !== 'undefined' && module.exports) { + module.exports = factory(); + return; + } + + root.IcloudUtils = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createIcloudUtils() { + function normalizeIcloudHost(rawHost) { + const host = String(rawHost || '').trim().toLowerCase(); + if (!host) return ''; + if (host === 'icloud.com' || host === 'www.icloud.com' || host === 'setup.icloud.com') return 'icloud.com'; + if (host === 'icloud.com.cn' || host === 'www.icloud.com.cn' || host === 'setup.icloud.com.cn') return 'icloud.com.cn'; + return ''; + } + + function getConfiguredIcloudHostPreference(stateOrValue = '') { + const preference = typeof stateOrValue === 'object' + ? String(stateOrValue?.icloudHostPreference || '').trim().toLowerCase() + : String(stateOrValue || '').trim().toLowerCase(); + if (!preference || preference === 'auto') return ''; + return normalizeIcloudHost(preference); + } + + function getIcloudLoginUrlForHost(host) { + const normalizedHost = normalizeIcloudHost(host); + if (normalizedHost === 'icloud.com') return 'https://www.icloud.com/'; + if (normalizedHost === 'icloud.com.cn') return 'https://www.icloud.com.cn/'; + return ''; + } + + function getIcloudSetupUrlForHost(host) { + const normalizedHost = normalizeIcloudHost(host); + if (normalizedHost === 'icloud.com') return 'https://setup.icloud.com/setup/ws/1'; + if (normalizedHost === 'icloud.com.cn') return 'https://setup.icloud.com.cn/setup/ws/1'; + return ''; + } + + function getIcloudHostHintFromMessage(message) { + const lower = String(message || '').toLowerCase(); + if (lower.includes('setup.icloud.com.cn') || lower.includes('www.icloud.com.cn') || lower.includes('icloud.com.cn')) { + return 'icloud.com.cn'; + } + if (lower.includes('setup.icloud.com') || lower.includes('www.icloud.com') || lower.includes('icloud.com')) { + return 'icloud.com'; + } + return ''; + } + + function normalizeBooleanMap(rawValue = {}) { + if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) { + return {}; + } + + return Object.entries(rawValue).reduce((result, [key, value]) => { + const normalizedKey = String(key || '').trim().toLowerCase(); + if (!normalizedKey) { + return result; + } + result[normalizedKey] = Boolean(value); + return result; + }, {}); + } + + function toNormalizedEmailSet(values = []) { + if (values instanceof Set) { + return new Set(Array.from(values, (item) => String(item || '').trim().toLowerCase()).filter(Boolean)); + } + if (Array.isArray(values)) { + return new Set(values.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean)); + } + if (values && typeof values === 'object') { + const normalizedMap = normalizeBooleanMap(values); + return new Set(Object.entries(normalizedMap) + .filter(([, value]) => value) + .map(([email]) => email)); + } + return new Set(); + } + + function findIcloudAliasArray(node, depth = 0) { + if (!node || depth > 4) return null; + if (Array.isArray(node)) { + return node.some((item) => item && typeof item === 'object') ? node : null; + } + if (typeof node !== 'object') return null; + + const priorityKeys = ['hmeEmails', 'hmeEmailList', 'hmeList', 'hmes', 'aliases', 'items']; + for (const key of priorityKeys) { + if (Array.isArray(node[key])) return node[key]; + } + + for (const value of Object.values(node)) { + const nested = findIcloudAliasArray(value, depth + 1); + if (nested) return nested; + } + + return null; + } + + function normalizeIcloudAliasRecord(raw, options = {}) { + const usedEmails = toNormalizedEmailSet(options.usedEmails); + const preservedEmails = toNormalizedEmailSet(options.preservedEmails); + const anonymousId = String(raw?.anonymousId || raw?.id || '').trim(); + const email = String( + raw?.hme + || raw?.email + || raw?.alias + || raw?.address + || raw?.metaData?.hme + || '' + ).trim().toLowerCase(); + + if (!email || !email.includes('@')) return null; + + const label = String(raw?.label || raw?.metaData?.label || '').trim(); + const note = String(raw?.note || raw?.metaData?.note || '').trim(); + const state = String(raw?.state || raw?.status || '').trim().toLowerCase(); + const createdAt = raw?.createTimestamp + || raw?.createTime + || raw?.createdAt + || raw?.createdDate + || null; + + return { + anonymousId, + email, + label, + note, + state, + active: raw?.active !== false && raw?.isActive !== false && state !== 'inactive' && state !== 'deleted', + used: usedEmails.has(email), + preserved: preservedEmails.has(email), + createdAt, + }; + } + + function normalizeIcloudAliasList(response, options = {}) { + const aliases = findIcloudAliasArray(response); + if (!aliases) return []; + + return aliases + .map((alias) => normalizeIcloudAliasRecord(alias, options)) + .filter(Boolean) + .sort((left, right) => { + if (left.active !== right.active) return left.active ? -1 : 1; + if (left.used !== right.used) return left.used ? 1 : -1; + return String(left.email).localeCompare(String(right.email)); + }); + } + + function pickReusableIcloudAlias(aliases = []) { + return (Array.isArray(aliases) ? aliases : []).find((alias) => alias?.active && !alias?.used) || null; + } + + function findIcloudAliasByEmail(aliases = [], email = '') { + const normalizedEmail = String(email || '').trim().toLowerCase(); + if (!normalizedEmail) return null; + return (Array.isArray(aliases) ? aliases : []) + .find((alias) => String(alias?.email || '').trim().toLowerCase() === normalizedEmail) || null; + } + + return { + findIcloudAliasArray, + findIcloudAliasByEmail, + getConfiguredIcloudHostPreference, + getIcloudHostHintFromMessage, + getIcloudLoginUrlForHost, + getIcloudSetupUrlForHost, + normalizeBooleanMap, + normalizeIcloudAliasList, + normalizeIcloudAliasRecord, + normalizeIcloudHost, + pickReusableIcloudAlias, + toNormalizedEmailSet, + }; +}); diff --git a/luckmail-utils.js b/luckmail-utils.js new file mode 100644 index 0000000..e4b404d --- /dev/null +++ b/luckmail-utils.js @@ -0,0 +1,445 @@ +(function luckmailUtilsModule(root, factory) { + if (typeof module !== 'undefined' && module.exports) { + module.exports = factory(); + return; + } + + root.LuckMailUtils = factory(); +})(typeof self !== 'undefined' ? self : globalThis, function createLuckmailUtils() { + const DEFAULT_LUCKMAIL_BASE_URL = 'https://mails.luckyous.com'; + const DEFAULT_LUCKMAIL_EMAIL_TYPE = 'ms_graph'; + const DEFAULT_LUCKMAIL_PROJECT_CODE = 'openai'; + const DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME = '保留'; + const LUCKMAIL_EMAIL_TYPES = ['self_built', 'ms_imap', 'ms_graph', 'google_variant']; + + 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 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 rawValue = String(value || '').trim(); + const utcLikeMatch = rawValue.match( + /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/ + ); + if (utcLikeMatch && !/[zZ]|[+\-]\d{2}:?\d{2}$/.test(rawValue)) { + const [, year, month, day, hour, minute, second = '0'] = utcLikeMatch; + return Date.UTC( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second), + 0 + ); + } + + const timestamp = Date.parse(rawValue); + return Number.isFinite(timestamp) ? timestamp : 0; + } + + function normalizeLuckmailBaseUrl(rawValue = '') { + const value = String(rawValue || '').trim(); + if (!value) return DEFAULT_LUCKMAIL_BASE_URL; + + try { + const parsed = new URL(value); + if (!['http:', 'https:'].includes(parsed.protocol)) { + return DEFAULT_LUCKMAIL_BASE_URL; + } + parsed.pathname = parsed.pathname.replace(/\/+$/, ''); + parsed.search = ''; + parsed.hash = ''; + return parsed.toString().replace(/\/$/, ''); + } catch { + return DEFAULT_LUCKMAIL_BASE_URL; + } + } + + function normalizeLuckmailEmailType(rawValue = '') { + const normalized = String(rawValue || '').trim().toLowerCase(); + return LUCKMAIL_EMAIL_TYPES.includes(normalized) + ? normalized + : DEFAULT_LUCKMAIL_EMAIL_TYPE; + } + + function normalizeLuckmailProjectName(rawValue = '') { + return normalizeText(rawValue); + } + + function extractLuckmailVerificationCode(text) { + const source = String(text || ''); + const matchCn = source.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/i); + if (matchCn) return matchCn[1]; + + const matchChatGPT = source.match(/your\s+chatgpt\s+code\s+is\s+(\d{6})/i); + if (matchChatGPT) return matchChatGPT[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 normalizeLuckmailTag(item = {}) { + const safeItem = item && typeof item === 'object' ? item : {}; + return { + id: Number(safeItem.id) || 0, + name: firstNonEmptyString([safeItem.name, safeItem.tag_name]), + remark: firstNonEmptyString([safeItem.remark]), + limit_type: Number(safeItem.limit_type) || 0, + purchase_count: Number(safeItem.purchase_count) || 0, + created_at: firstNonEmptyString([safeItem.created_at]) || null, + }; + } + + function normalizeLuckmailTags(input) { + const list = Array.isArray(input?.list) + ? input.list + : (Array.isArray(input) ? input : []); + return list + .map((item) => normalizeLuckmailTag(item)) + .filter((item) => item.id > 0 || item.name); + } + + function normalizeLuckmailPurchase(item = {}) { + const safeItem = item && typeof item === 'object' ? item : {}; + const projectName = firstNonEmptyString([safeItem.project_name, safeItem.project]); + return { + id: Number(safeItem.id) || 0, + email_address: firstNonEmptyString([safeItem.email_address, safeItem.address]), + token: firstNonEmptyString([safeItem.token]), + project_name: projectName, + project_code: normalizeLuckmailProjectName(projectName), + price: firstNonEmptyString([safeItem.price]) || '0.0000', + status: Number(safeItem.status) || 0, + tag_id: Number(safeItem.tag_id) || 0, + tag_name: firstNonEmptyString([safeItem.tag_name]), + user_disabled: Number(safeItem.user_disabled) || 0, + warranty_hours: Number(safeItem.warranty_hours) || 0, + warranty_until: firstNonEmptyString([safeItem.warranty_until]) || null, + created_at: firstNonEmptyString([safeItem.created_at]) || null, + }; + } + + function normalizeLuckmailPurchases(result) { + const list = Array.isArray(result?.purchases) + ? result.purchases + : (Array.isArray(result) ? result : []); + return list.map((item) => normalizeLuckmailPurchase(item)); + } + + function normalizeLuckmailPurchaseListPage(result = {}) { + const safeResult = result && typeof result === 'object' ? result : {}; + const list = Array.isArray(safeResult.list) + ? safeResult.list + : (Array.isArray(safeResult.purchases) ? safeResult.purchases : []); + const total = Number(safeResult.total); + return { + list: list.map((item) => normalizeLuckmailPurchase(item)), + total: Number.isFinite(total) && total >= 0 ? total : 0, + page: Math.max(1, Number(safeResult.page) || 1), + page_size: Math.max(1, Number(safeResult.page_size || safeResult.pageSize) || list.length || 1), + }; + } + + function normalizeLuckmailPurchaseId(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric <= 0) { + return ''; + } + return String(Math.floor(numeric)); + } + + function normalizeLuckmailUsedPurchases(rawValue = {}) { + if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) { + return {}; + } + + return Object.entries(rawValue).reduce((result, [key, value]) => { + const normalizedKey = normalizeLuckmailPurchaseId(key); + if (!normalizedKey) { + return result; + } + result[normalizedKey] = Boolean(value); + return result; + }, {}); + } + + function isLuckmailPurchaseForProject(purchase, projectCode = DEFAULT_LUCKMAIL_PROJECT_CODE) { + const normalizedPurchase = normalizeLuckmailPurchase(purchase); + return normalizeLuckmailProjectName(normalizedPurchase.project_name) === normalizeLuckmailProjectName(projectCode); + } + + function normalizeLuckmailTokenMail(mail = {}) { + const safeMail = mail && typeof mail === 'object' ? mail : {}; + const subject = firstNonEmptyString([safeMail.subject, safeMail.title]); + const body = firstNonEmptyString([safeMail.body, safeMail.body_text, safeMail.text]); + const htmlBody = firstNonEmptyString([safeMail.html_body, safeMail.body_html, safeMail.html]); + const from = firstNonEmptyString([safeMail.from, safeMail.sender]); + const verificationCode = firstNonEmptyString([safeMail.verification_code]) + || extractLuckmailVerificationCode([subject, body, htmlBody, from].filter(Boolean).join(' ')); + + return { + message_id: firstNonEmptyString([safeMail.message_id, safeMail.id]), + from, + subject, + body, + html_body: htmlBody, + received_at: firstNonEmptyString([safeMail.received_at, safeMail.receivedAt, safeMail.created_at]), + verification_code: verificationCode || '', + }; + } + + function normalizeLuckmailTokenMails(input) { + const list = Array.isArray(input?.mails) + ? input.mails + : (Array.isArray(input) ? input : []); + return list.map((mail) => normalizeLuckmailTokenMail(mail)); + } + + function normalizeLuckmailTokenCode(result = {}) { + return { + email_address: firstNonEmptyString([result.email_address, result.address]), + project: firstNonEmptyString([result.project]), + has_new_mail: Boolean(result.has_new_mail), + verification_code: firstNonEmptyString([result.verification_code]) || null, + mail: result.mail ? normalizeLuckmailTokenMail(result.mail) : null, + }; + } + + function normalizeLuckmailMailCursor(cursor = {}) { + const safeCursor = cursor && typeof cursor === 'object' ? cursor : {}; + return { + messageId: firstNonEmptyString([safeCursor.messageId, safeCursor.message_id]), + receivedAt: firstNonEmptyString([safeCursor.receivedAt, safeCursor.received_at]), + }; + } + + function buildLuckmailMailCursor(mail = {}) { + const normalizedMail = normalizeLuckmailTokenMail(mail); + return normalizeLuckmailMailCursor({ + messageId: normalizedMail.message_id, + receivedAt: normalizedMail.received_at, + }); + } + + function buildLuckmailBaselineCursor(mails) { + const latestMail = normalizeLuckmailTokenMails(mails) + .sort((left, right) => { + const leftTimestamp = normalizeTimestamp(left.received_at); + const rightTimestamp = normalizeTimestamp(right.received_at); + if (leftTimestamp !== rightTimestamp) { + return rightTimestamp - leftTimestamp; + } + return String(right.message_id || '').localeCompare(String(left.message_id || '')); + })[0] || null; + + return latestMail ? buildLuckmailMailCursor(latestMail) : null; + } + + function isLuckmailMailNewerThanCursor(mail = {}, cursor = {}) { + const normalizedMail = normalizeLuckmailTokenMail(mail); + const normalizedCursor = normalizeLuckmailMailCursor(cursor); + + if (!normalizedCursor.messageId && !normalizedCursor.receivedAt) { + return true; + } + + if (normalizedMail.message_id && normalizedCursor.messageId && normalizedMail.message_id === normalizedCursor.messageId) { + return false; + } + + const mailTimestamp = normalizeTimestamp(normalizedMail.received_at); + const cursorTimestamp = normalizeTimestamp(normalizedCursor.receivedAt); + + if (mailTimestamp && cursorTimestamp) { + if (mailTimestamp > cursorTimestamp) return true; + if (mailTimestamp < cursorTimestamp) return false; + return Boolean( + normalizedMail.message_id + && normalizedCursor.messageId + && normalizedMail.message_id !== normalizedCursor.messageId + ); + } + + if (normalizedMail.message_id && normalizedCursor.messageId) { + return normalizedMail.message_id !== normalizedCursor.messageId; + } + + return !cursorTimestamp || Boolean(mailTimestamp && mailTimestamp > cursorTimestamp); + } + + function isLuckmailPurchaseExpired(purchase, now = Date.now()) { + const normalizedPurchase = normalizeLuckmailPurchase(purchase); + const expiresAt = normalizeTimestamp(normalizedPurchase.warranty_until); + return Boolean(expiresAt && expiresAt <= Number(now || 0)); + } + + function isLuckmailPurchaseDisabled(purchase) { + return Number(normalizeLuckmailPurchase(purchase).user_disabled) === 1; + } + + function isLuckmailPurchasePreserved(purchase, options = {}) { + const normalizedPurchase = normalizeLuckmailPurchase(purchase); + const expectedTagId = Number(options.preserveTagId) || 0; + const expectedTagName = normalizeText(options.preserveTagName || DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME); + + if (expectedTagId > 0 && normalizedPurchase.tag_id === expectedTagId) { + return true; + } + + return Boolean(normalizedPurchase.tag_name && normalizeText(normalizedPurchase.tag_name) === expectedTagName); + } + + function isLuckmailPurchaseReusable(purchase, options = {}) { + const normalizedPurchase = normalizeLuckmailPurchase(purchase); + const usedPurchases = normalizeLuckmailUsedPurchases(options.usedPurchases); + const purchaseId = normalizeLuckmailPurchaseId(normalizedPurchase.id); + + if (!isLuckmailPurchaseForProject(normalizedPurchase, options.projectCode || DEFAULT_LUCKMAIL_PROJECT_CODE)) { + return false; + } + if (!normalizedPurchase.email_address || !normalizedPurchase.token) { + return false; + } + if (isLuckmailPurchaseDisabled(normalizedPurchase)) { + return false; + } + if (purchaseId && usedPurchases[purchaseId]) { + return false; + } + if (isLuckmailPurchasePreserved(normalizedPurchase, options)) { + return false; + } + if (isLuckmailPurchaseExpired(normalizedPurchase, options.now || Date.now())) { + return false; + } + return true; + } + + function filterReusableLuckmailPurchases(purchases, options = {}) { + const list = Array.isArray(purchases) + ? purchases + : normalizeLuckmailPurchaseListPage(purchases).list; + return list + .map((purchase) => normalizeLuckmailPurchase(purchase)) + .filter((purchase) => isLuckmailPurchaseReusable(purchase, options)); + } + + function pickReusableLuckmailPurchase(purchases, options = {}) { + return filterReusableLuckmailPurchases(purchases, options)[0] || null; + } + + function mailMatchesLuckmailFilters(mail, filters = {}) { + const normalizedMail = normalizeLuckmailTokenMail(mail); + const afterTimestamp = normalizeTimestamp(filters.afterTimestamp); + const receivedAt = normalizeTimestamp(normalizedMail.received_at); + if (afterTimestamp && receivedAt && receivedAt < afterTimestamp) { + return null; + } + + const senderFilters = (filters.senderFilters || []).map(normalizeText).filter(Boolean); + const subjectFilters = (filters.subjectFilters || []).map(normalizeText).filter(Boolean); + const excludedCodes = new Set((filters.excludeCodes || []).filter(Boolean)); + const combinedText = [ + normalizedMail.subject, + normalizedMail.from, + normalizedMail.body, + normalizedMail.html_body, + ].filter(Boolean).join(' '); + const combinedTextNormalized = normalizeText(combinedText); + const senderNormalized = normalizeText(normalizedMail.from); + const subjectNormalized = normalizeText(normalizedMail.subject); + const code = normalizedMail.verification_code || extractLuckmailVerificationCode(combinedText); + + if (!code || excludedCodes.has(code)) { + return null; + } + + const senderMatch = senderFilters.length === 0 + ? true + : senderFilters.some((item) => senderNormalized.includes(item) || combinedTextNormalized.includes(item)); + const subjectMatch = subjectFilters.length === 0 + ? true + : subjectFilters.some((item) => subjectNormalized.includes(item) || combinedTextNormalized.includes(item)); + + if (!senderMatch && !subjectMatch) { + return null; + } + + return { + code, + mail: normalizedMail, + receivedAt, + }; + } + + function pickLuckmailVerificationMail(mails, filters = {}) { + const matches = normalizeLuckmailTokenMails(mails) + .map((mail) => mailMatchesLuckmailFilters(mail, filters)) + .filter(Boolean) + .sort((left, right) => { + if (left.receivedAt !== right.receivedAt) { + return right.receivedAt - left.receivedAt; + } + return String(right.mail.message_id || '').localeCompare(String(left.mail.message_id || '')); + }); + + return matches[0] || null; + } + + return { + DEFAULT_LUCKMAIL_BASE_URL, + DEFAULT_LUCKMAIL_EMAIL_TYPE, + DEFAULT_LUCKMAIL_PROJECT_CODE, + DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME, + LUCKMAIL_EMAIL_TYPES, + buildLuckmailBaselineCursor, + buildLuckmailMailCursor, + extractLuckmailVerificationCode, + filterReusableLuckmailPurchases, + isLuckmailMailNewerThanCursor, + isLuckmailPurchaseDisabled, + isLuckmailPurchaseExpired, + isLuckmailPurchaseForProject, + isLuckmailPurchasePreserved, + isLuckmailPurchaseReusable, + normalizeLuckmailBaseUrl, + normalizeLuckmailEmailType, + normalizeLuckmailMailCursor, + normalizeLuckmailProjectName, + normalizeLuckmailPurchase, + normalizeLuckmailPurchaseId, + normalizeLuckmailPurchaseListPage, + normalizeLuckmailPurchases, + normalizeLuckmailTag, + normalizeLuckmailTags, + normalizeLuckmailTokenCode, + normalizeLuckmailTokenMail, + normalizeLuckmailTokenMails, + normalizeLuckmailUsedPurchases, + normalizeText, + normalizeTimestamp, + pickLuckmailVerificationMail, + pickReusableLuckmailPurchase, + }; +}); diff --git a/manifest.json b/manifest.json index f0196b9..53a06b4 100644 --- a/manifest.json +++ b/manifest.json @@ -15,11 +15,22 @@ "activeTab" ], "host_permissions": [ + "https://*.icloud.com/*", + "https://*.icloud.com.cn/*", "<all_urls>" ], "background": { "service_worker": "background.js" }, + "declarative_net_request": { + "rule_resources": [ + { + "id": "icloud_headers", + "enabled": true, + "path": "rules.json" + } + ] + }, "side_panel": { "default_path": "sidepanel/sidepanel.html" }, diff --git a/microsoft-email.js b/microsoft-email.js index f4fdcec..131af23 100644 --- a/microsoft-email.js +++ b/microsoft-email.js @@ -4,21 +4,91 @@ /auth0\.openai\.com/i, ]; const CODE_PATTERN = /\b(\d{6})\b/; - const TOKEN_ENDPOINT = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token'; - const OUTLOOK_API_BASE = 'https://outlook.office.com/api/v2.0/me/messages'; + const GRAPH_SCOPES = 'offline_access https://graph.microsoft.com/Mail.Read https://graph.microsoft.com/User.Read'; + const GRAPH_DEFAULT_SCOPE = 'https://graph.microsoft.com/.default'; + const TOKEN_STRATEGIES = [ + { + name: 'entra-common-delegated', + url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + extraData: { scope: GRAPH_SCOPES }, + }, + { + name: 'entra-consumers-delegated', + url: 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token', + extraData: { scope: GRAPH_SCOPES }, + }, + { + name: 'entra-common-default', + url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + extraData: { scope: GRAPH_DEFAULT_SCOPE }, + }, + { + name: 'entra-common-outlook', + url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + extraData: {}, + }, + ]; + const TRANSPORT_PLANS = [ + { + transport: 'graph', + strategyNames: ['entra-common-delegated', 'entra-consumers-delegated', 'entra-common-default'], + }, + { + transport: 'outlook', + strategyNames: ['entra-common-outlook', 'entra-common-delegated', 'entra-consumers-delegated'], + }, + ]; + const GRAPH_API_BASE = 'https://graph.microsoft.com/v1.0/me/mailFolders'; + const OUTLOOK_API_BASE = 'https://outlook.office.com/api/v2.0/me/mailfolders'; + + function getFetchImpl(fetchImpl) { + const resolved = fetchImpl || globalScope.fetch; + if (typeof resolved !== 'function') { + throw new Error('Microsoft email helper requires a fetch implementation.'); + } + return resolved; + } + + function resolveTokenStrategy(name) { + return TOKEN_STRATEGIES.find((item) => item.name === name) || TOKEN_STRATEGIES[0]; + } + + function normalizeMailboxLabel(mailbox = 'INBOX') { + return /^junk(?:\s*e-?mail|\s*email)?$/i.test(String(mailbox || '').trim()) ? 'Junk' : 'INBOX'; + } + + function normalizeMailboxId(mailbox = 'INBOX') { + return normalizeMailboxLabel(mailbox) === 'Junk' ? 'junkemail' : 'inbox'; + } + + function normalizeMailboxList(mailboxes) { + const list = Array.isArray(mailboxes) && mailboxes.length ? mailboxes : ['INBOX']; + return [...new Set(list.map((mailbox) => normalizeMailboxLabel(mailbox)))]; + } + + async function getResponseErrorText(response) { + const text = await response.text().catch(() => ''); + if (!text) { + return response.statusText || `HTTP ${response.status}`; + } + try { + const parsed = JSON.parse(text); + return parsed.error_description || parsed.error?.message || parsed.error || parsed.message || text; + } catch { + return text; + } + } async function exchangeRefreshToken(clientId, refreshToken, options = {}) { - const fetchImpl = options.fetchImpl || globalScope.fetch; - if (typeof fetchImpl !== 'function') { - throw new Error('Microsoft 邮箱 helper 缺少 fetch 实现。'); - } - + const fetchImpl = getFetchImpl(options.fetchImpl); + const strategy = resolveTokenStrategy(options.strategyName); const body = new URLSearchParams({ client_id: clientId, grant_type: 'refresh_token', refresh_token: refreshToken, + ...(strategy.extraData || {}), }); - const response = await fetchImpl(TOKEN_ENDPOINT, { + const response = await fetchImpl(strategy.url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), @@ -26,75 +96,89 @@ }); if (!response.ok) { - const text = await response.text().catch(() => ''); - const errorInfo = (() => { - try { - return JSON.parse(text); - } catch { - return {}; - } - })(); - throw new Error( - errorInfo.error_description - || `Token exchange failed (${response.status}): ${text.slice(0, 200)}` - ); + throw new Error(`${strategy.name}: ${await getResponseErrorText(response)}`); } const data = await response.json(); if (!data.access_token) { - throw new Error('Token exchange response missing access_token.'); + throw new Error(`${strategy.name}: token response missing access_token`); } - return data; + + return { + ...data, + tokenStrategy: strategy.name, + }; } - async function fetchOutlookMessages(accessToken, options = {}) { - const { top = 5, signal } = options; - const fetchImpl = options.fetchImpl || globalScope.fetch; - if (typeof fetchImpl !== 'function') { - throw new Error('Microsoft 邮箱 helper 缺少 fetch 实现。'); - } - - const response = await fetchImpl( - `${OUTLOOK_API_BASE}?$top=${encodeURIComponent(top)}&$orderby=ReceivedDateTime desc&$select=From,Subject,ReceivedDateTime,BodyPreview,Body`, - { - method: 'GET', - headers: { Authorization: `Bearer ${accessToken}` }, - signal, - } - ); - - if (response.status === 401 || response.status === 403) { - throw new Error('Microsoft Graph token invalid or expired.'); - } + async function fetchGraphMessages(accessToken, options = {}) { + const fetchImpl = getFetchImpl(options.fetchImpl); + const mailbox = normalizeMailboxLabel(options.mailbox); + const top = Math.max(1, Math.min(Number(options.top) || 5, 30)); + const url = `${GRAPH_API_BASE}/${normalizeMailboxId(mailbox)}/messages?$top=${encodeURIComponent(top)}&$select=id,internetMessageId,subject,from,bodyPreview,receivedDateTime&$orderby=receivedDateTime desc`; + const response = await fetchImpl(url, { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + signal: options.signal, + }); if (!response.ok) { - const body = await response.text().catch(() => ''); - throw new Error(`Outlook API request failed (${response.status}): ${body || response.statusText}`); + throw new Error(`graph: ${await getResponseErrorText(response)}`); } const payload = await response.json(); return Array.isArray(payload?.value) ? payload.value : []; } - function normalizeMessage(message) { + async function fetchOutlookMessages(accessToken, options = {}) { + const fetchImpl = getFetchImpl(options.fetchImpl); + const mailbox = normalizeMailboxLabel(options.mailbox); + const top = Math.max(1, Math.min(Number(options.top) || 5, 30)); + const url = `${OUTLOOK_API_BASE}/${normalizeMailboxId(mailbox)}/messages?$top=${encodeURIComponent(top)}&$select=Id,Subject,From,BodyPreview,Body,ReceivedDateTime&$orderby=ReceivedDateTime desc`; + const response = await fetchImpl(url, { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + signal: options.signal, + }); + + if (!response.ok) { + throw new Error(`outlook: ${await getResponseErrorText(response)}`); + } + + const payload = await response.json(); + return Array.isArray(payload?.value) ? payload.value : []; + } + + function normalizeMessage(message, mailbox = 'INBOX') { + const sender = message?.From || message?.from || {}; + const emailAddress = sender?.EmailAddress || sender?.emailAddress || {}; return { + mailbox: normalizeMailboxLabel(mailbox || message?.mailbox), from: { emailAddress: { - address: message?.From?.EmailAddress?.Address - || message?.from?.emailAddress?.address - || '', + address: String(emailAddress?.Address || emailAddress?.address || '').trim(), + name: String(emailAddress?.Name || emailAddress?.name || '').trim(), }, }, - subject: message?.Subject || message?.subject || '', - receivedDateTime: message?.ReceivedDateTime || message?.receivedDateTime || '', - bodyPreview: message?.BodyPreview || message?.bodyPreview || '', + subject: String(message?.Subject || message?.subject || '').trim(), + receivedDateTime: String(message?.ReceivedDateTime || message?.receivedDateTime || '').trim(), + bodyPreview: String(message?.BodyPreview || message?.bodyPreview || '').trim(), body: { - content: message?.Body?.Content || message?.body?.content || '', + content: String(message?.Body?.Content || message?.body?.content || '').trim(), }, - id: message?.Id || message?.id || '', + id: String(message?.Id || message?.id || message?.internetMessageId || '').trim(), }; } + function normalizeFilterValue(value) { + return String(value || '').trim().toLowerCase(); + } + function getMessageSender(message) { return String( message?.from?.emailAddress?.address @@ -130,29 +214,54 @@ } function extractVerificationCodeFromMessages(messages, options = {}) { - const { filterAfterTimestamp = 0 } = options; + const filterAfterTimestamp = Number(options.filterAfterTimestamp || 0) || 0; + const senderFilters = (options.senderFilters || []).map(normalizeFilterValue).filter(Boolean); + const subjectFilters = (options.subjectFilters || []).map(normalizeFilterValue).filter(Boolean); + const excludedCodes = new Set((options.excludeCodes || []).map((value) => String(value || '').trim()).filter(Boolean)); + const hasExplicitFilters = senderFilters.length > 0 || subjectFilters.length > 0; - for (const raw of messages) { - const message = normalizeMessage(raw); + const sortedMessages = (Array.isArray(messages) ? messages : []) + .map((raw) => normalizeMessage(raw, raw?.mailbox)) + .sort((left, right) => getMessageTimestamp(right) - getMessageTimestamp(left)); + + for (const message of sortedMessages) { const receivedAt = getMessageTimestamp(message); - if (receivedAt && receivedAt < Number(filterAfterTimestamp || 0)) { - continue; - } - if (!isOpenAiMessage(message)) { + if (receivedAt && receivedAt < filterAfterTimestamp) { continue; } - const match = getMessageSearchText(message).match(CODE_PATTERN); - if (!match) { + const sender = normalizeFilterValue(getMessageSender(message)); + const subject = normalizeFilterValue(message?.subject); + const preview = normalizeFilterValue(message?.bodyPreview); + const searchText = normalizeFilterValue(getMessageSearchText(message)); + const codeMatch = getMessageSearchText(message).match(CODE_PATTERN); + const code = codeMatch?.[1] || ''; + if (!code || excludedCodes.has(code)) { + continue; + } + + if (!hasExplicitFilters && !isOpenAiMessage(message)) { + continue; + } + + const senderMatched = senderFilters.length === 0 + ? true + : senderFilters.some((filter) => sender.includes(filter) || preview.includes(filter) || searchText.includes(filter)); + const subjectMatched = subjectFilters.length === 0 + ? true + : subjectFilters.some((filter) => subject.includes(filter) || preview.includes(filter) || searchText.includes(filter)); + if (!senderMatched && !subjectMatched) { continue; } return { - code: match[1], + code, emailTimestamp: receivedAt || Date.now(), messageId: message?.id || null, sender: getMessageSender(message), subject: String(message?.subject || ''), + mailbox: message?.mailbox || 'INBOX', + message, }; } @@ -163,9 +272,11 @@ const { clientId, refreshToken, + mailbox = 'INBOX', top = 5, fetchImpl, signal, + log = null, } = options; if (!refreshToken) { @@ -175,71 +286,160 @@ throw new Error('Microsoft client_id is empty.'); } - const tokenData = await exchangeRefreshToken(clientId, refreshToken, { fetchImpl, signal }); - const rawMessages = await fetchOutlookMessages(tokenData.access_token, { top, signal, fetchImpl }); + const errors = []; + for (const plan of TRANSPORT_PLANS) { + for (const strategyName of plan.strategyNames) { + try { + const tokenData = await exchangeRefreshToken(clientId, refreshToken, { + fetchImpl, + signal, + strategyName, + }); + const rawMessages = plan.transport === 'graph' + ? await fetchGraphMessages(tokenData.access_token, { mailbox, top, fetchImpl, signal }) + : await fetchOutlookMessages(tokenData.access_token, { mailbox, top, fetchImpl, signal }); - return { - tokenData, - nextRefreshToken: String(tokenData?.refresh_token || '').trim(), - messages: rawMessages.map((message) => normalizeMessage(message)), - }; + return { + tokenData, + nextRefreshToken: String(tokenData?.refresh_token || '').trim(), + tokenStrategy: strategyName, + transport: plan.transport, + mailbox: normalizeMailboxLabel(mailbox), + messages: rawMessages.map((message) => normalizeMessage(message, mailbox)), + }; + } catch (error) { + const message = error?.message || String(error); + errors.push(`${plan.transport}/${strategyName}: ${message}`); + if (typeof log === 'function') { + log(`mailbox=${normalizeMailboxLabel(mailbox)} ${plan.transport}/${strategyName} failed: ${message}`); + } + } + } + } + + throw new Error(`Microsoft mailbox request failed: ${errors.join(' | ')}`); + } + + function delay(timeoutMs, signal) { + if (timeoutMs <= 0) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + resolve(); + }, timeoutMs); + const onAbort = () => { + cleanup(); + reject(signal.reason || new Error('Aborted')); + }; + const cleanup = () => { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + }; + + if (signal?.aborted) { + cleanup(); + reject(signal.reason || new Error('Aborted')); + return; + } + + signal?.addEventListener('abort', onAbort, { once: true }); + }); } async function fetchMicrosoftVerificationCode(options = {}) { const { token, + refreshToken, clientId, maxRetries = 3, retryDelayMs = 10000, + top = 5, log = null, filterAfterTimestamp = 0, + senderFilters = [], + subjectFilters = [], + excludeCodes = [], + mailboxes = ['INBOX'], fetchImpl, signal, } = options; - if (!token) { + let workingRefreshToken = String(refreshToken || token || '').trim(); + if (!workingRefreshToken) { throw new Error('Microsoft refresh token is empty.'); } if (!clientId) { throw new Error('Microsoft client_id is empty.'); } - const tokenData = await exchangeRefreshToken(clientId, token, { fetchImpl, signal }); - const accessToken = tokenData.access_token; - const nextRefreshToken = String(tokenData?.refresh_token || '').trim(); + const normalizedMailboxes = normalizeMailboxList(mailboxes); + let lastError = null; for (let attempt = 1; attempt <= maxRetries; attempt += 1) { - const messages = await fetchOutlookMessages(accessToken, { top: 5, signal, fetchImpl }); - const result = extractVerificationCodeFromMessages(messages, { filterAfterTimestamp }); - if (result) { - return { - ...result, - nextRefreshToken, - messages: messages.map((message) => normalizeMessage(message)), - }; + try { + const collectedMessages = []; + for (const mailbox of normalizedMailboxes) { + const result = await fetchMicrosoftMailboxMessages({ + clientId, + refreshToken: workingRefreshToken, + mailbox, + top, + fetchImpl, + signal, + log, + }); + if (result.nextRefreshToken) { + workingRefreshToken = result.nextRefreshToken; + } + collectedMessages.push(...result.messages); + } + + const match = extractVerificationCodeFromMessages(collectedMessages, { + filterAfterTimestamp, + senderFilters, + subjectFilters, + excludeCodes, + }); + if (match) { + return { + ...match, + nextRefreshToken: workingRefreshToken, + messages: collectedMessages, + }; + } + + lastError = new Error('No matching Microsoft verification email found.'); + } catch (error) { + lastError = error; } if (attempt < maxRetries) { if (typeof log === 'function') { - log(`Outlook API: attempt ${attempt}/${maxRetries} found no OpenAI verification mail, retrying...`); + log(`attempt ${attempt}/${maxRetries} found no matching Microsoft mail, retrying...`); } - await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + await delay(retryDelayMs, signal); } } - throw new Error('No matching OpenAI verification email found.'); + throw lastError || new Error('No matching Microsoft verification email found.'); } const api = { CODE_PATTERN, exchangeRefreshToken, extractVerificationCodeFromMessages, + fetchGraphMessages, fetchMicrosoftMailboxMessages, fetchMicrosoftVerificationCode, fetchOutlookMessages, getMessageSender, getMessageTimestamp, isOpenAiMessage, + normalizeMailboxId, + normalizeMailboxLabel, normalizeMessage, }; diff --git a/rules.json b/rules.json new file mode 100644 index 0000000..3bb7919 --- /dev/null +++ b/rules.json @@ -0,0 +1,50 @@ +[ + { + "id": 1, + "priority": 1, + "action": { + "type": "modifyHeaders", + "requestHeaders": [ + { + "header": "Origin", + "operation": "set", + "value": "https://www.icloud.com" + }, + { + "header": "Referer", + "operation": "set", + "value": "https://www.icloud.com/" + } + ] + }, + "condition": { + "urlFilter": "|https://*.icloud.com/*", + "resourceTypes": ["xmlhttprequest"], + "excludedInitiatorDomains": ["apple.com", "icloud.com"] + } + }, + { + "id": 2, + "priority": 1, + "action": { + "type": "modifyHeaders", + "requestHeaders": [ + { + "header": "Origin", + "operation": "set", + "value": "https://www.icloud.com.cn" + }, + { + "header": "Referer", + "operation": "set", + "value": "https://www.icloud.com.cn/" + } + ] + }, + "condition": { + "urlFilter": "|https://*.icloud.com.cn/*", + "resourceTypes": ["xmlhttprequest"], + "excludedInitiatorDomains": ["apple.com.cn", "icloud.com.cn"] + } + } +] diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index e6bbe94..b4503c3 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -698,6 +698,164 @@ header { margin-top: 10px; } +.luckmail-card { + margin-top: 10px; +} + +.luckmail-manager-card { + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-base) 84%, transparent); + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.luckmail-summary { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.6; +} + +.luckmail-toolbar { + display: flex; + gap: 8px; + align-items: center; +} + +.luckmail-search { + flex: 1; +} + +.luckmail-filter { + flex: 0 0 130px; +} + +.luckmail-bulkbar { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; +} + +.luckmail-select-all { + flex: 0 0 auto; +} + +.luckmail-bulk-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.luckmail-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 360px; + overflow: auto; + padding-right: 4px; +} + +.luckmail-item { + border: 1px solid var(--border); + background: var(--bg-base); + border-radius: var(--radius-sm); + padding: 10px; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 10px; + align-items: flex-start; +} + +.luckmail-item.is-current { + border-color: var(--blue); + box-shadow: 0 0 0 1px var(--blue-glow); +} + +.luckmail-item-check { + margin-top: 4px; +} + +.luckmail-item-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.luckmail-item-email-row { + display: flex; + align-items: center; + gap: 6px; +} + +.luckmail-item-email { + font-weight: 600; + color: var(--text-primary); + word-break: break-all; +} + +.luckmail-item-meta, +.luckmail-item-details { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.luckmail-item-details { + color: var(--text-secondary); + font-size: 12px; +} + +.luckmail-item-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.luckmail-tag { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + background: var(--bg-surface); + color: var(--text-secondary); +} + +.luckmail-tag.used { + background: var(--orange-soft); + color: var(--orange); +} + +.luckmail-tag.active { + background: var(--green-soft); + color: var(--green); +} + +.luckmail-tag.current { + background: var(--blue-soft); + color: var(--blue); +} + +.luckmail-tag.disabled { + background: var(--red-soft); + color: var(--red); +} + +.luckmail-empty { + color: var(--text-muted); + font-size: 12px; + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + padding: 12px; + text-align: center; +} + .hotmail-actions-row .data-label { visibility: hidden; } @@ -877,6 +1035,163 @@ header { text-align: center; } +.icloud-card { + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--bg-base) 84%, transparent); + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.icloud-summary { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.6; +} + +.icloud-login-help { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + border: 1px solid color-mix(in srgb, var(--orange) 26%, var(--border)); + background: color-mix(in srgb, var(--orange-soft) 62%, var(--bg-base)); + border-radius: var(--radius-sm); + padding: 10px; +} + +.icloud-login-help-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.icloud-login-help-title { + color: var(--text-primary); + font-weight: 700; + font-size: 13px; +} + +.icloud-login-help-text { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.5; +} + +.icloud-toolbar { + display: flex; + gap: 8px; + align-items: center; +} + +.icloud-search { + flex: 1; +} + +.icloud-filter { + flex: 0 0 130px; +} + +.icloud-bulkbar { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; +} + +.icloud-select-all { + flex: 0 0 auto; +} + +.icloud-bulk-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.icloud-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 360px; + overflow: auto; + padding-right: 4px; +} + +.icloud-item { + border: 1px solid var(--border); + background: var(--bg-base); + border-radius: var(--radius-sm); + padding: 10px; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 10px; + align-items: flex-start; +} + +.icloud-item-check { + margin-top: 4px; +} + +.icloud-item-main { + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.icloud-item-actions { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.icloud-item-email { + font-weight: 600; + color: var(--text-primary); + word-break: break-all; +} + +.icloud-item-meta { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.icloud-tag { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + background: var(--bg-surface); + color: var(--text-secondary); +} + +.icloud-tag.used { + background: var(--orange-soft); + color: var(--orange); +} + +.icloud-tag.active { + background: var(--green-soft); + color: var(--green); +} + +.icloud-empty { + color: var(--text-muted); + font-size: 12px; + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + padding: 12px; + text-align: center; +} + .data-select { flex: 1; padding: 7px 10px; diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html index ba1ec7b..133a64a 100644 --- a/sidepanel/sidepanel.html +++ b/sidepanel/sidepanel.html @@ -154,22 +154,54 @@ <select id="select-mail-provider" class="data-select"> <option value="custom">自定义邮箱</option> <option value="hotmail-api">Hotmail(账号池)</option> + <option value="luckmail-api">LuckMail(API 购邮)</option> <option value="163">163 邮箱 (mail.163.com)</option> <option value="163-vip">163 VIP 邮箱 (vip.163.com)</option> <option value="qq">QQ 邮箱 (wx.mail.qq.com)</option> <option value="inbucket">Inbucket(自定义主机)</option> <option value="2925">2925 邮箱 (2925.com)</option> + <option value="cloudflare-temp-email">Cloudflare Temp Email</option> </select> <button id="btn-mail-login" class="btn btn-outline btn-sm data-inline-btn" type="button" disabled>登录</button> </div> </div> + <div class="data-row" id="row-mail-2925-mode" style="display:none;"> + <span class="data-label">2925 模式</span> + <div id="mail-2925-mode-group" class="choice-group" role="group" aria-label="2925 邮箱模式"> + <button type="button" class="choice-btn" data-mail2925-mode="provide">提供邮箱</button> + <button type="button" class="choice-btn" data-mail2925-mode="receive">接收邮箱</button> + </div> + </div> <div class="data-row" id="row-email-generator"> <span class="data-label">邮箱生成</span> <select id="select-email-generator" class="data-select"> <option value="duck">DuckDuckGo</option> <option value="cloudflare">Cloudflare</option> + <option value="icloud">iCloud 隐私邮箱</option> + <option value="cloudflare-temp-email">Cloudflare Temp Email</option> </select> </div> + <div class="data-row" id="row-temp-email-base-url" style="display:none;"> + <span class="data-label">Temp API</span> + <input type="text" id="input-temp-email-base-url" class="data-input" placeholder="https://your-worker-domain" /> + </div> + <div class="data-row" id="row-temp-email-admin-auth" style="display:none;"> + <span class="data-label">Admin Auth</span> + <input type="password" id="input-temp-email-admin-auth" class="data-input data-input-with-icon" placeholder="Cloudflare Temp Email admin password" /> + </div> + <div class="data-row" id="row-temp-email-custom-auth" style="display:none;"> + <span class="data-label">Custom Auth</span> + <input type="password" id="input-temp-email-custom-auth" class="data-input data-input-with-icon" placeholder="仅当站点启用了访问密码时再填写;这是额外鉴权,不替代 Admin Auth。" /> + </div> + <div class="data-row" id="row-temp-email-domain" style="display:none;"> + <span class="data-label">Temp 域名</span> + <div class="data-inline"> + <select id="select-temp-email-domain" class="data-select"></select> + <input type="text" id="input-temp-email-domain" class="data-input" placeholder="例如 mail.example.com" + style="display:none;" /> + <button id="btn-temp-email-domain-mode" class="btn btn-outline btn-sm" type="button">添加</button> + </div> + </div> <div class="data-row" id="row-cf-domain" style="display:none;"> <span class="data-label">CF 域名</span> <div class="data-inline"> @@ -316,6 +348,137 @@ <div id="hotmail-accounts-list" class="hotmail-accounts-list"></div> </div> </div> + <div id="luckmail-section" class="data-card luckmail-card" style="display:none;"> + <div class="section-mini-header"> + <div class="section-mini-copy"> + <span class="section-label">LuckMail 配置</span> + </div> + </div> + <div class="data-row"> + <span class="data-label">API Key</span> + <input type="password" id="input-luckmail-api-key" class="data-input mono" placeholder="请输入 LuckMail API Key" /> + </div> + <div class="data-row"> + <span class="data-label">Base URL</span> + <input type="text" id="input-luckmail-base-url" class="data-input mono" placeholder="https://mails.luckyous.com" /> + </div> + <div class="data-row"> + <span class="data-label">邮箱类型</span> + <select id="select-luckmail-email-type" class="data-select"> + <option value="self_built">自建邮箱</option> + <option value="ms_imap">微软 IMAP</option> + <option value="ms_graph">微软 Graph</option> + <option value="google_variant">谷歌变种</option> + </select> + </div> + <div class="data-row"> + <span class="data-label">域名</span> + <input type="text" id="input-luckmail-domain" class="data-input mono" placeholder="可选,留空则由 LuckMail 默认处理" /> + </div> + <div class="data-row"> + <span class="data-label">项目</span> + <span class="data-value mono">openai</span> + </div> + <div class="luckmail-manager-card"> + <div class="section-mini-header"> + <div class="section-mini-copy"> + <span class="section-label">LuckMail 邮箱列表</span> + <span class="data-value">仅显示项目为 <span class="mono">openai</span> 的邮箱</span> + </div> + <div class="section-mini-actions"> + <button id="btn-luckmail-refresh" class="btn btn-ghost btn-xs" type="button">刷新</button> + <button id="btn-luckmail-disable-used" class="btn btn-ghost btn-xs" type="button">禁用已用</button> + </div> + </div> + <div id="luckmail-summary" class="luckmail-summary">加载已购邮箱后可在这里管理 openai 项目的 LuckMail 邮箱。</div> + <div class="luckmail-toolbar"> + <input id="input-luckmail-search" class="data-input luckmail-search" type="text" placeholder="搜索邮箱 / 标签 / 项目" /> + <select id="select-luckmail-filter" class="data-select luckmail-filter"> + <option value="all">全部</option> + <option value="reusable">可复用</option> + <option value="used">已用</option> + <option value="unused">未用</option> + <option value="preserved">保留</option> + <option value="disabled">已禁用</option> + </select> + </div> + <div class="luckmail-bulkbar"> + <label class="option-toggle luckmail-select-all" for="checkbox-luckmail-select-all"> + <input type="checkbox" id="checkbox-luckmail-select-all" /> + <span id="luckmail-selection-summary">已选 0 个</span> + </label> + <div class="luckmail-bulk-actions"> + <button id="btn-luckmail-bulk-used" class="btn btn-outline btn-xs" type="button">标记已用</button> + <button id="btn-luckmail-bulk-unused" class="btn btn-outline btn-xs" type="button">标记未用</button> + <button id="btn-luckmail-bulk-preserve" class="btn btn-outline btn-xs" type="button">保留</button> + <button id="btn-luckmail-bulk-unpreserve" class="btn btn-outline btn-xs" type="button">取消保留</button> + <button id="btn-luckmail-bulk-disable" class="btn btn-outline btn-xs" type="button">禁用</button> + <button id="btn-luckmail-bulk-enable" class="btn btn-outline btn-xs" type="button">启用</button> + </div> + </div> + <div id="luckmail-list" class="luckmail-list"></div> + </div> + </div> + <div id="icloud-section" class="data-card hotmail-card" style="display:none;"> + <div class="section-mini-header"> + <div class="section-mini-copy"> + <span class="section-label">iCloud 隐私邮箱</span> + </div> + <div class="section-mini-actions"> + <button id="btn-icloud-refresh" class="btn btn-ghost btn-xs" type="button">刷新</button> + <button id="btn-icloud-delete-used" class="btn btn-ghost btn-xs" type="button">删除已用</button> + </div> + </div> + <div class="data-row"> + <span class="data-label">iCloud</span> + <select id="select-icloud-host-preference" class="data-select"> + <option value="auto">自动</option> + <option value="icloud.com">iCloud.com</option> + <option value="icloud.com.cn">iCloud.com.cn</option> + </select> + </div> + <div class="data-row"> + <span class="data-label">自动删除</span> + <label class="option-toggle" for="checkbox-auto-delete-icloud"> + <input type="checkbox" id="checkbox-auto-delete-icloud" /> + <span>成功使用后自动删除 iCloud 别名</span> + </label> + </div> + <div class="icloud-card"> + <div id="icloud-summary" class="icloud-summary">加载你的 iCloud Hide My Email 别名以便在这里管理。</div> + <div id="icloud-login-help" class="icloud-login-help" style="display:none;"> + <div class="icloud-login-help-main"> + <div id="icloud-login-help-title" class="icloud-login-help-title">需要登录 iCloud</div> + <div id="icloud-login-help-text" class="icloud-login-help-text">我已经为你打开 iCloud 登录页。请在那个页面完成登录,然后回到这里点击“我已登录”。</div> + </div> + <button id="btn-icloud-login-done" class="btn btn-primary btn-xs" type="button">我已登录</button> + </div> + <div class="icloud-toolbar"> + <input id="input-icloud-search" class="data-input icloud-search" type="text" placeholder="搜索邮箱 / 标签 / 备注" /> + <select id="select-icloud-filter" class="data-select icloud-filter"> + <option value="all">全部</option> + <option value="active">可用</option> + <option value="used">已用</option> + <option value="unused">未用</option> + <option value="preserved">保留</option> + </select> + </div> + <div class="icloud-bulkbar"> + <label class="option-toggle icloud-select-all" for="checkbox-icloud-select-all"> + <input type="checkbox" id="checkbox-icloud-select-all" /> + <span id="icloud-selection-summary">已选 0 个</span> + </label> + <div class="icloud-bulk-actions"> + <button id="btn-icloud-bulk-used" class="btn btn-outline btn-xs" type="button">标记已用</button> + <button id="btn-icloud-bulk-unused" class="btn btn-outline btn-xs" type="button">标记未用</button> + <button id="btn-icloud-bulk-preserve" class="btn btn-outline btn-xs" type="button">保留</button> + <button id="btn-icloud-bulk-unpreserve" class="btn btn-outline btn-xs" type="button">取消保留</button> + <button id="btn-icloud-bulk-delete" class="btn btn-outline btn-xs" type="button">删除</button> + </div> + </div> + <div id="icloud-list" class="icloud-list"></div> + </div> + </div> <div id="status-bar" class="status-bar"> <div class="status-dot"></div> <span id="display-status">就绪</span> @@ -432,6 +595,7 @@ <div id="toast-container"></div> <input id="input-import-settings-file" type="file" accept=".json,application/json" hidden /> <script src="../hotmail-utils.js"></script> + <script src="../luckmail-utils.js"></script> <script src="update-service.js"></script> <script src="sidepanel.js"></script> </body> diff --git a/sidepanel/sidepanel.js b/sidepanel/sidepanel.js index 95a0b12..55537d7 100644 --- a/sidepanel/sidepanel.js +++ b/sidepanel/sidepanel.js @@ -66,9 +66,42 @@ const rowSub2ApiGroup = document.getElementById('row-sub2api-group'); const inputSub2ApiGroup = document.getElementById('input-sub2api-group'); const selectMailProvider = document.getElementById('select-mail-provider'); const btnMailLogin = document.getElementById('btn-mail-login'); +const rowMail2925Mode = document.getElementById('row-mail-2925-mode'); +const mail2925ModeButtons = Array.from(document.querySelectorAll('[data-mail2925-mode]')); const rowEmailGenerator = document.getElementById('row-email-generator'); const selectEmailGenerator = document.getElementById('select-email-generator'); +const rowTempEmailBaseUrl = document.getElementById('row-temp-email-base-url'); +const inputTempEmailBaseUrl = document.getElementById('input-temp-email-base-url'); +const rowTempEmailAdminAuth = document.getElementById('row-temp-email-admin-auth'); +const inputTempEmailAdminAuth = document.getElementById('input-temp-email-admin-auth'); +const rowTempEmailCustomAuth = document.getElementById('row-temp-email-custom-auth'); +const inputTempEmailCustomAuth = document.getElementById('input-temp-email-custom-auth'); +const rowTempEmailDomain = document.getElementById('row-temp-email-domain'); +const selectTempEmailDomain = document.getElementById('select-temp-email-domain'); +const inputTempEmailDomain = document.getElementById('input-temp-email-domain'); +const btnTempEmailDomainMode = document.getElementById('btn-temp-email-domain-mode'); const hotmailSection = document.getElementById('hotmail-section'); +const luckmailSection = document.getElementById('luckmail-section'); +const icloudSection = document.getElementById('icloud-section'); +const icloudSummary = document.getElementById('icloud-summary'); +const icloudList = document.getElementById('icloud-list'); +const icloudLoginHelp = document.getElementById('icloud-login-help'); +const icloudLoginHelpTitle = document.getElementById('icloud-login-help-title'); +const icloudLoginHelpText = document.getElementById('icloud-login-help-text'); +const btnIcloudLoginDone = document.getElementById('btn-icloud-login-done'); +const btnIcloudRefresh = document.getElementById('btn-icloud-refresh'); +const btnIcloudDeleteUsed = document.getElementById('btn-icloud-delete-used'); +const selectIcloudHostPreference = document.getElementById('select-icloud-host-preference'); +const checkboxAutoDeleteIcloud = document.getElementById('checkbox-auto-delete-icloud'); +const inputIcloudSearch = document.getElementById('input-icloud-search'); +const selectIcloudFilter = document.getElementById('select-icloud-filter'); +const checkboxIcloudSelectAll = document.getElementById('checkbox-icloud-select-all'); +const icloudSelectionSummary = document.getElementById('icloud-selection-summary'); +const btnIcloudBulkUsed = document.getElementById('btn-icloud-bulk-used'); +const btnIcloudBulkUnused = document.getElementById('btn-icloud-bulk-unused'); +const btnIcloudBulkPreserve = document.getElementById('btn-icloud-bulk-preserve'); +const btnIcloudBulkUnpreserve = document.getElementById('btn-icloud-bulk-unpreserve'); +const btnIcloudBulkDelete = document.getElementById('btn-icloud-bulk-delete'); const rowHotmailServiceMode = document.getElementById('row-hotmail-service-mode'); const hotmailServiceModeButtons = Array.from(document.querySelectorAll('[data-hotmail-service-mode]')); const rowHotmailRemoteBaseUrl = document.getElementById('row-hotmail-remote-base-url'); @@ -88,6 +121,24 @@ const btnDeleteAllHotmailAccounts = document.getElementById('btn-delete-all-hotm const btnToggleHotmailList = document.getElementById('btn-toggle-hotmail-list'); const hotmailListShell = document.getElementById('hotmail-list-shell'); const hotmailAccountsList = document.getElementById('hotmail-accounts-list'); +const inputLuckmailApiKey = document.getElementById('input-luckmail-api-key'); +const inputLuckmailBaseUrl = document.getElementById('input-luckmail-base-url'); +const selectLuckmailEmailType = document.getElementById('select-luckmail-email-type'); +const inputLuckmailDomain = document.getElementById('input-luckmail-domain'); +const btnLuckmailRefresh = document.getElementById('btn-luckmail-refresh'); +const btnLuckmailDisableUsed = document.getElementById('btn-luckmail-disable-used'); +const luckmailSummary = document.getElementById('luckmail-summary'); +const inputLuckmailSearch = document.getElementById('input-luckmail-search'); +const selectLuckmailFilter = document.getElementById('select-luckmail-filter'); +const checkboxLuckmailSelectAll = document.getElementById('checkbox-luckmail-select-all'); +const luckmailSelectionSummary = document.getElementById('luckmail-selection-summary'); +const btnLuckmailBulkUsed = document.getElementById('btn-luckmail-bulk-used'); +const btnLuckmailBulkUnused = document.getElementById('btn-luckmail-bulk-unused'); +const btnLuckmailBulkPreserve = document.getElementById('btn-luckmail-bulk-preserve'); +const btnLuckmailBulkUnpreserve = document.getElementById('btn-luckmail-bulk-unpreserve'); +const btnLuckmailBulkDisable = document.getElementById('btn-luckmail-bulk-disable'); +const btnLuckmailBulkEnable = document.getElementById('btn-luckmail-bulk-enable'); +const luckmailList = document.getElementById('luckmail-list'); const rowEmailPrefix = document.getElementById('row-email-prefix'); const labelEmailPrefix = document.getElementById('label-email-prefix'); const inputEmailPrefix = document.getElementById('input-email-prefix'); @@ -139,12 +190,18 @@ const AUTO_RUN_MAX_RETRIES_PER_ROUND = 3; const AUTO_STEP_DELAY_MIN_SECONDS = 0; const AUTO_STEP_DELAY_MAX_SECONDS = 600; const DEFAULT_LOCAL_CPA_STEP9_MODE = 'submit'; +const MAIL_2925_MODE_PROVIDE = 'provide'; +const MAIL_2925_MODE_RECEIVE = 'receive'; +const DEFAULT_MAIL_2925_MODE = MAIL_2925_MODE_PROVIDE; const AUTO_SKIP_FAILURES_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-auto-skip-failures-prompt-dismissed'; const AUTO_RUN_FALLBACK_RISK_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-auto-run-fallback-risk-prompt-dismissed'; const AUTO_RUN_FALLBACK_RISK_WARNING_MIN_RUNS = 15; const AUTO_RUN_FALLBACK_RISK_RECOMMENDED_THREAD_INTERVAL_MINUTES = 5; const HOTMAIL_SERVICE_MODE_REMOTE = 'remote'; const HOTMAIL_SERVICE_MODE_LOCAL = 'local'; +const LUCKMAIL_PROVIDER = 'luckmail-api'; +const DEFAULT_LUCKMAIL_BASE_URL = 'https://mails.luckyous.com'; +const DEFAULT_LUCKMAIL_EMAIL_TYPE = 'ms_graph'; const DISPLAY_TIMEZONE = 'Asia/Shanghai'; let latestState = null; @@ -163,6 +220,12 @@ let settingsDirty = false; let settingsSaveInFlight = false; let settingsAutoSaveTimer = null; let cloudflareDomainEditMode = false; +let cloudflareTempEmailDomainEditMode = false; +let icloudRefreshQueued = false; +let lastRenderedIcloudAliases = []; +let icloudSelectedEmails = new Set(); +let icloudSearchTerm = ''; +let icloudFilterMode = 'all'; let modalChoiceResolver = null; let currentModalActions = []; let modalResultBuilder = null; @@ -183,22 +246,42 @@ const upsertHotmailAccountInList = window.HotmailUtils?.upsertHotmailAccountInLi const filterHotmailAccountsByUsage = window.HotmailUtils?.filterHotmailAccountsByUsage; const getHotmailBulkActionLabel = window.HotmailUtils?.getHotmailBulkActionLabel; const getHotmailListToggleLabel = window.HotmailUtils?.getHotmailListToggleLabel; +const normalizeLuckmailTimestampValue = window.LuckMailUtils?.normalizeTimestamp + || ((value) => { + const timestamp = Date.parse(String(value || '')); + return Number.isFinite(timestamp) ? timestamp : 0; + }); const HOTMAIL_LIST_EXPANDED_STORAGE_KEY = 'multipage-hotmail-list-expanded'; const sidepanelUpdateService = window.SidepanelUpdateService; +const DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME = window.LuckMailUtils?.DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME || '保留'; + +let lastRenderedLuckmailPurchases = []; +let luckmailSelectedPurchaseIds = new Set(); +let luckmailSearchTerm = ''; +let luckmailFilterMode = 'all'; +let luckmailRefreshQueued = false; btnAutoCancelSchedule?.remove(); const MAIL_PROVIDER_LOGIN_CONFIGS = { '163': { label: '163 邮箱', url: 'https://mail.163.com/', + buttonLabel: '登录', }, '163-vip': { label: '163 VIP 邮箱', - url: 'https://vip.163.com/', + url: 'https://webmail.vip.163.com/', + buttonLabel: '登录', }, qq: { label: 'QQ 邮箱', url: 'https://wx.mail.qq.com/', + buttonLabel: '登录', + }, + 'cloudflare-temp-email': { + label: 'Cloudflare Temp Email GitHub', + url: 'https://github.com/dreamhunter2333/cloudflare_temp_email', + buttonLabel: 'GitHub', }, '2925': { label: '2925 邮箱', @@ -227,7 +310,8 @@ const LOG_LEVEL_LABELS = { }; function usesGeneratedAliasMailProvider(provider) { - return provider === '2925'; + return String(provider || '').trim() === '2925' + && getSelectedMail2925Mode() === MAIL_2925_MODE_PROVIDE; } function showToast(message, type = 'error', duration = 4000) { @@ -860,6 +944,37 @@ function normalizeCloudflareDomains(values = []) { return domains; } +function normalizeCloudflareTempEmailBaseUrlValue(value = '') { + const raw = String(value || '').trim(); + if (!raw) return ''; + const candidate = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(raw) ? raw : `https://${raw}`; + try { + const parsed = new URL(candidate); + parsed.hash = ''; + parsed.search = ''; + const pathname = parsed.pathname === '/' ? '' : parsed.pathname.replace(/\/+$/, ''); + return `${parsed.origin}${pathname}`; + } catch { + return ''; + } +} + +function normalizeCloudflareTempEmailDomainValue(value = '') { + return normalizeCloudflareDomainValue(value); +} + +function normalizeCloudflareTempEmailDomains(values = []) { + const seen = new Set(); + const domains = []; + for (const value of Array.isArray(values) ? values : []) { + const normalized = normalizeCloudflareTempEmailDomainValue(value); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + domains.push(normalized); + } + return domains; +} + function getCloudflareDomainsFromState() { const domains = normalizeCloudflareDomains(latestState?.cloudflareDomains || []); const activeDomain = normalizeCloudflareDomainValue(latestState?.cloudflareDomain || ''); @@ -869,6 +984,15 @@ function getCloudflareDomainsFromState() { return { domains, activeDomain: activeDomain || domains[0] || '' }; } +function getCloudflareTempEmailDomainsFromState() { + const domains = normalizeCloudflareTempEmailDomains(latestState?.cloudflareTempEmailDomains || []); + const activeDomain = normalizeCloudflareTempEmailDomainValue(latestState?.cloudflareTempEmailDomain || ''); + if (activeDomain && !domains.includes(activeDomain)) { + domains.unshift(activeDomain); + } + return { domains, activeDomain: activeDomain || domains[0] || '' }; +} + function renderCloudflareDomainOptions(preferredDomain = '') { const preferred = normalizeCloudflareDomainValue(preferredDomain); const { domains, activeDomain } = getCloudflareDomainsFromState(); @@ -895,6 +1019,32 @@ function renderCloudflareDomainOptions(preferredDomain = '') { selectCfDomain.value = domains.includes(selected) ? selected : domains[0]; } +function renderCloudflareTempEmailDomainOptions(preferredDomain = '') { + const preferred = normalizeCloudflareTempEmailDomainValue(preferredDomain); + const { domains, activeDomain } = getCloudflareTempEmailDomainsFromState(); + const selected = preferred || activeDomain; + + selectTempEmailDomain.innerHTML = ''; + if (domains.length === 0) { + const option = document.createElement('option'); + option.value = ''; + option.textContent = '请先添加域名'; + selectTempEmailDomain.appendChild(option); + selectTempEmailDomain.disabled = true; + selectTempEmailDomain.value = ''; + return; + } + + for (const domain of domains) { + const option = document.createElement('option'); + option.value = domain; + option.textContent = domain; + selectTempEmailDomain.appendChild(option); + } + selectTempEmailDomain.disabled = false; + selectTempEmailDomain.value = domains.includes(selected) ? selected : domains[0]; +} + function setCloudflareDomainEditMode(editing, options = {}) { const { clearInput = false } = options; cloudflareDomainEditMode = Boolean(editing); @@ -911,11 +1061,31 @@ function setCloudflareDomainEditMode(editing, options = {}) { } } +function setCloudflareTempEmailDomainEditMode(editing, options = {}) { + const { clearInput = false } = options; + cloudflareTempEmailDomainEditMode = Boolean(editing); + selectTempEmailDomain.style.display = cloudflareTempEmailDomainEditMode ? 'none' : ''; + inputTempEmailDomain.style.display = cloudflareTempEmailDomainEditMode ? '' : 'none'; + btnTempEmailDomainMode.textContent = cloudflareTempEmailDomainEditMode ? '保存' : '添加'; + if (cloudflareTempEmailDomainEditMode) { + if (clearInput) { + inputTempEmailDomain.value = ''; + } + inputTempEmailDomain.focus(); + } else if (clearInput) { + inputTempEmailDomain.value = ''; + } +} + function collectSettingsPayload() { const { domains, activeDomain } = getCloudflareDomainsFromState(); const selectedCloudflareDomain = normalizeCloudflareDomainValue( !cloudflareDomainEditMode ? selectCfDomain.value : activeDomain ) || activeDomain; + const { domains: tempEmailDomains, activeDomain: tempEmailActiveDomain } = getCloudflareTempEmailDomainsFromState(); + const selectedCloudflareTempEmailDomain = normalizeCloudflareTempEmailDomainValue( + !cloudflareTempEmailDomainEditMode ? selectTempEmailDomain.value : tempEmailActiveDomain + ) || tempEmailActiveDomain; return { panelMode: selectPanelMode.value, vpsUrl: inputVpsUrl.value.trim(), @@ -927,15 +1097,27 @@ function collectSettingsPayload() { sub2apiGroupName: inputSub2ApiGroup.value.trim(), customPassword: inputPassword.value, mailProvider: selectMailProvider.value, + mail2925Mode: getSelectedMail2925Mode(), emailGenerator: selectEmailGenerator.value, + autoDeleteUsedIcloudAlias: checkboxAutoDeleteIcloud?.checked, + icloudHostPreference: selectIcloudHostPreference?.value || 'auto', emailPrefix: inputEmailPrefix.value.trim(), inbucketHost: inputInbucketHost.value.trim(), inbucketMailbox: inputInbucketMailbox.value.trim(), hotmailServiceMode: getSelectedHotmailServiceMode(), hotmailRemoteBaseUrl: inputHotmailRemoteBaseUrl.value.trim(), hotmailLocalBaseUrl: inputHotmailLocalBaseUrl.value.trim(), + luckmailApiKey: inputLuckmailApiKey.value, + luckmailBaseUrl: normalizeLuckmailBaseUrl(inputLuckmailBaseUrl.value), + luckmailEmailType: normalizeLuckmailEmailType(selectLuckmailEmailType.value), + luckmailDomain: inputLuckmailDomain.value.trim(), cloudflareDomain: selectedCloudflareDomain, cloudflareDomains: domains, + cloudflareTempEmailBaseUrl: normalizeCloudflareTempEmailBaseUrlValue(inputTempEmailBaseUrl.value), + cloudflareTempEmailAdminAuth: inputTempEmailAdminAuth.value, + cloudflareTempEmailCustomAuth: inputTempEmailCustomAuth.value, + cloudflareTempEmailDomain: selectedCloudflareTempEmailDomain, + cloudflareTempEmailDomains: tempEmailDomains, autoRunSkipFailures: inputAutoSkipFailures.checked, autoRunFallbackThreadIntervalMinutes: normalizeAutoRunThreadIntervalMinutes(inputAutoSkipFailuresThreadIntervalMinutes.value), autoRunDelayEnabled: inputAutoDelayEnabled.checked, @@ -950,6 +1132,12 @@ function normalizeLocalCpaStep9Mode(value = '') { : DEFAULT_LOCAL_CPA_STEP9_MODE; } +function normalizeMail2925Mode(value = '') { + return String(value || '').trim().toLowerCase() === MAIL_2925_MODE_RECEIVE + ? MAIL_2925_MODE_RECEIVE + : DEFAULT_MAIL_2925_MODE; +} + function normalizeHotmailServiceMode(value = '') { if (typeof normalizeHotmailServiceModeFromUtils === 'function') { return normalizeHotmailServiceModeFromUtils(value); @@ -973,6 +1161,20 @@ function setLocalCpaStep9Mode(mode) { }); } +function getSelectedMail2925Mode() { + const activeButton = mail2925ModeButtons.find((button) => button.classList.contains('is-active')); + return normalizeMail2925Mode(activeButton?.dataset.mail2925Mode); +} + +function setMail2925Mode(mode) { + const resolvedMode = normalizeMail2925Mode(mode); + mail2925ModeButtons.forEach((button) => { + const active = button.dataset.mail2925Mode === resolvedMode; + button.classList.toggle('is-active', active); + button.setAttribute('aria-pressed', String(active)); + }); +} + function getSelectedHotmailServiceMode() { const activeButton = hotmailServiceModeButtons.find((button) => button.classList.contains('is-active')); return normalizeHotmailServiceMode(activeButton?.dataset.hotmailServiceMode); @@ -1211,22 +1413,51 @@ function applySettingsState(state) { inputSub2ApiPassword.value = state?.sub2apiPassword || ''; inputSub2ApiGroup.value = state?.sub2apiGroupName || ''; const restoredMailProvider = isCustomMailProvider(state?.mailProvider) - || ['hotmail-api', '163', '163-vip', 'qq', 'inbucket', '2925'].includes(String(state?.mailProvider || '').trim()) + || ['hotmail-api', 'luckmail-api', '163', '163-vip', 'qq', 'inbucket', '2925', 'cloudflare-temp-email'].includes(String(state?.mailProvider || '').trim()) ? String(state?.mailProvider || '163').trim() : (String(state?.emailGenerator || '').trim().toLowerCase() === 'custom' || String(state?.emailGenerator || '').trim().toLowerCase() === 'manual' ? 'custom' : '163'); selectMailProvider.value = restoredMailProvider; - selectEmailGenerator.value = String(state?.emailGenerator || '').trim().toLowerCase() === 'cloudflare' ? 'cloudflare' : 'duck'; + setMail2925Mode(state?.mail2925Mode); + { + const restoredEmailGenerator = String(state?.emailGenerator || '').trim().toLowerCase(); + if (restoredEmailGenerator === 'icloud') { + selectEmailGenerator.value = 'icloud'; + } else if (restoredEmailGenerator === 'cloudflare') { + selectEmailGenerator.value = 'cloudflare'; + } else if (restoredEmailGenerator === 'cloudflare-temp-email') { + selectEmailGenerator.value = 'cloudflare-temp-email'; + } else { + selectEmailGenerator.value = 'duck'; + } + } + if (selectIcloudHostPreference) { + selectIcloudHostPreference.value = String(state?.icloudHostPreference || '').trim().toLowerCase() === 'icloud.com' + ? 'icloud.com' + : (String(state?.icloudHostPreference || '').trim().toLowerCase() === 'icloud.com.cn' ? 'icloud.com.cn' : 'auto'); + } + if (checkboxAutoDeleteIcloud) { + checkboxAutoDeleteIcloud.checked = Boolean(state?.autoDeleteUsedIcloudAlias); + } inputEmailPrefix.value = state?.emailPrefix || ''; inputInbucketHost.value = state?.inbucketHost || ''; inputInbucketMailbox.value = state?.inbucketMailbox || ''; setHotmailServiceMode(state?.hotmailServiceMode); inputHotmailRemoteBaseUrl.value = state?.hotmailRemoteBaseUrl || ''; inputHotmailLocalBaseUrl.value = state?.hotmailLocalBaseUrl || ''; + inputLuckmailApiKey.value = state?.luckmailApiKey || ''; + inputLuckmailBaseUrl.value = normalizeLuckmailBaseUrl(state?.luckmailBaseUrl); + selectLuckmailEmailType.value = normalizeLuckmailEmailType(state?.luckmailEmailType); + inputLuckmailDomain.value = state?.luckmailDomain || ''; + inputTempEmailBaseUrl.value = state?.cloudflareTempEmailBaseUrl || ''; + inputTempEmailAdminAuth.value = state?.cloudflareTempEmailAdminAuth || ''; + inputTempEmailCustomAuth.value = state?.cloudflareTempEmailCustomAuth || ''; renderCloudflareDomainOptions(state?.cloudflareDomain || ''); setCloudflareDomainEditMode(false, { clearInput: true }); + renderCloudflareTempEmailDomainOptions(state?.cloudflareTempEmailDomain || ''); + setCloudflareTempEmailDomainEditMode(false, { clearInput: true }); inputAutoSkipFailures.checked = Boolean(state?.autoRunSkipFailures); inputAutoSkipFailuresThreadIntervalMinutes.value = String(normalizeAutoRunThreadIntervalMinutes(state?.autoRunFallbackThreadIntervalMinutes)); inputAutoDelayEnabled.checked = Boolean(state?.autoRunDelayEnabled); @@ -1242,6 +1473,9 @@ function applySettingsState(state) { updateFallbackThreadIntervalInputState(); updatePanelModeUI(); updateMailProviderUI(); + if (isLuckmailProvider(state?.mailProvider)) { + queueLuckmailPurchaseRefresh(); + } updateButtonStates(); } @@ -1249,6 +1483,9 @@ async function restoreState() { try { const state = await chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' }); applySettingsState(state); + if (getSelectedEmailGenerator() === 'icloud' && icloudSection?.style.display !== 'none') { + refreshIcloudAliases({ silent: true }).catch(() => { }); + } if (state.oauthUrl) { displayOauthUrl.textContent = state.oauthUrl; @@ -1491,15 +1728,62 @@ function isCustomMailProvider(provider = selectMailProvider.value) { return String(provider || '').trim().toLowerCase() === 'custom'; } +function isLuckmailProvider(provider = selectMailProvider.value) { + return String(provider || '').trim().toLowerCase() === LUCKMAIL_PROVIDER; +} + +function normalizeLuckmailBaseUrl(value = '') { + const trimmed = String(value || '').trim(); + if (!trimmed) { + return DEFAULT_LUCKMAIL_BASE_URL; + } + + try { + const parsed = new URL(trimmed); + if (!['http:', 'https:'].includes(parsed.protocol)) { + return DEFAULT_LUCKMAIL_BASE_URL; + } + parsed.pathname = parsed.pathname.replace(/\/+$/, ''); + parsed.search = ''; + parsed.hash = ''; + return parsed.toString().replace(/\/$/, ''); + } catch { + return DEFAULT_LUCKMAIL_BASE_URL; + } +} + +function normalizeLuckmailEmailType(value = '') { + const normalized = String(value || '').trim().toLowerCase(); + return ['self_built', 'ms_imap', 'ms_graph', 'google_variant'].includes(normalized) + ? normalized + : DEFAULT_LUCKMAIL_EMAIL_TYPE; +} + function getSelectedEmailGenerator() { const generator = String(selectEmailGenerator.value || '').trim().toLowerCase(); - if (generator === 'cloudflare') { - return 'cloudflare'; + if (generator === 'custom' || generator === 'manual') { + return 'custom'; } + if (generator === 'icloud') { + return 'icloud'; + } + if (generator === 'cloudflare') return 'cloudflare'; + if (generator === 'cloudflare-temp-email') return 'cloudflare-temp-email'; return 'duck'; } function getEmailGeneratorUiCopy() { + if (getSelectedEmailGenerator() === 'custom') { + return getCustomMailProviderUiCopy(); + } + if (getSelectedEmailGenerator() === 'icloud') { + return { + buttonLabel: '获取', + placeholder: '点击获取 iCloud 隐私邮箱,或手动粘贴邮箱', + successVerb: '获取', + label: 'iCloud 隐私邮箱', + }; + } if (getSelectedEmailGenerator() === 'cloudflare') { return { buttonLabel: '生成', @@ -1508,6 +1792,14 @@ function getEmailGeneratorUiCopy() { label: 'Cloudflare 邮箱', }; } + if (getSelectedEmailGenerator() === 'cloudflare-temp-email') { + return { + buttonLabel: '生成 Temp', + placeholder: '点击生成 Cloudflare Temp Email,或手动粘贴邮箱', + successVerb: '生成', + label: 'Cloudflare Temp Email', + }; + } return { buttonLabel: '获取', @@ -1551,6 +1843,417 @@ function getCurrentHotmailEmail(state = latestState) { return String(getCurrentHotmailAccount(state)?.email || '').trim(); } +function getCurrentLuckmailPurchase(state = latestState) { + return state?.currentLuckmailPurchase || null; +} + +function getCurrentLuckmailEmail(state = latestState) { + return String(getCurrentLuckmailPurchase(state)?.email_address || '').trim(); +} + +function getLuckmailUsedPurchases(state = latestState) { + const rawValue = state?.luckmailUsedPurchases; + if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) { + return {}; + } + + return Object.entries(rawValue).reduce((result, [key, value]) => { + const numeric = Number(key); + if (!Number.isFinite(numeric) || numeric <= 0) { + return result; + } + result[String(Math.floor(numeric))] = Boolean(value); + return result; + }, {}); +} + +function normalizeLuckmailProjectName(value = '') { + return String(value || '').trim().toLowerCase(); +} + +function getLuckmailPreserveTagName(state = latestState) { + return String(state?.luckmailPreserveTagName || '').trim() || DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME; +} + +function formatLuckmailDateTime(value) { + const timestamp = normalizeLuckmailTimestampValue(value); + if (!timestamp) { + return String(value || '').trim() || '未知'; + } + return new Date(timestamp).toLocaleString('zh-CN', { + hour12: false, + timeZone: DISPLAY_TIMEZONE, + }); +} + +function normalizeLuckmailSearchText(value) { + return String(value || '').trim().toLowerCase(); +} + +function getFilteredLuckmailPurchases(purchases = lastRenderedLuckmailPurchases) { + const searchTerm = normalizeLuckmailSearchText(luckmailSearchTerm); + return (Array.isArray(purchases) ? purchases : []).filter((purchase) => { + const matchesFilter = (() => { + switch (luckmailFilterMode) { + case 'reusable': return Boolean(purchase.reusable); + case 'used': return Boolean(purchase.used); + case 'unused': return !purchase.used; + case 'preserved': return Boolean(purchase.preserved); + case 'disabled': return Boolean(purchase.disabled); + default: return true; + } + })(); + + if (!matchesFilter) return false; + if (!searchTerm) return true; + + const haystack = [ + purchase.email_address, + purchase.project_name, + purchase.tag_name, + purchase.used ? '已用 used' : '未用 unused', + purchase.preserved ? '保留 preserved' : '', + purchase.disabled ? '已禁用 disabled' : '', + purchase.reusable ? '可复用 reusable' : '', + ].join(' ').toLowerCase(); + + return haystack.includes(searchTerm); + }); +} + +function pruneLuckmailSelection(purchases = lastRenderedLuckmailPurchases) { + const existingIds = new Set((Array.isArray(purchases) ? purchases : []).map((purchase) => String(purchase.id))); + luckmailSelectedPurchaseIds = new Set([...luckmailSelectedPurchaseIds].filter((id) => existingIds.has(id))); +} + +function updateLuckmailBulkUI(visiblePurchases = getFilteredLuckmailPurchases()) { + if (!checkboxLuckmailSelectAll || !luckmailSelectionSummary) { + return; + } + + const visibleIds = visiblePurchases.map((purchase) => String(purchase.id)); + const selectedVisibleCount = visibleIds.filter((id) => luckmailSelectedPurchaseIds.has(id)).length; + const hasVisible = visibleIds.length > 0; + const hasSelection = luckmailSelectedPurchaseIds.size > 0; + + checkboxLuckmailSelectAll.checked = hasVisible && selectedVisibleCount === visibleIds.length; + checkboxLuckmailSelectAll.indeterminate = selectedVisibleCount > 0 && selectedVisibleCount < visibleIds.length; + checkboxLuckmailSelectAll.disabled = !hasVisible; + luckmailSelectionSummary.textContent = `已选 ${luckmailSelectedPurchaseIds.size} 个(当前显示 ${visibleIds.length} 个)`; + + if (btnLuckmailBulkUsed) btnLuckmailBulkUsed.disabled = !hasSelection; + if (btnLuckmailBulkUnused) btnLuckmailBulkUnused.disabled = !hasSelection; + if (btnLuckmailBulkPreserve) btnLuckmailBulkPreserve.disabled = !hasSelection; + if (btnLuckmailBulkUnpreserve) btnLuckmailBulkUnpreserve.disabled = !hasSelection; + if (btnLuckmailBulkDisable) btnLuckmailBulkDisable.disabled = !hasSelection; + if (btnLuckmailBulkEnable) btnLuckmailBulkEnable.disabled = !hasSelection; +} + +function setLuckmailLoadingState(loading, summary = '') { + if (btnLuckmailRefresh) btnLuckmailRefresh.disabled = loading; + if (btnLuckmailDisableUsed) btnLuckmailDisableUsed.disabled = loading; + if (inputLuckmailSearch) inputLuckmailSearch.disabled = loading; + if (selectLuckmailFilter) selectLuckmailFilter.disabled = loading; + if (checkboxLuckmailSelectAll) checkboxLuckmailSelectAll.disabled = loading || getFilteredLuckmailPurchases().length === 0; + if (btnLuckmailBulkUsed) btnLuckmailBulkUsed.disabled = loading || luckmailSelectedPurchaseIds.size === 0; + if (btnLuckmailBulkUnused) btnLuckmailBulkUnused.disabled = loading || luckmailSelectedPurchaseIds.size === 0; + if (btnLuckmailBulkPreserve) btnLuckmailBulkPreserve.disabled = loading || luckmailSelectedPurchaseIds.size === 0; + if (btnLuckmailBulkUnpreserve) btnLuckmailBulkUnpreserve.disabled = loading || luckmailSelectedPurchaseIds.size === 0; + if (btnLuckmailBulkDisable) btnLuckmailBulkDisable.disabled = loading || luckmailSelectedPurchaseIds.size === 0; + if (btnLuckmailBulkEnable) btnLuckmailBulkEnable.disabled = loading || luckmailSelectedPurchaseIds.size === 0; + if (summary && luckmailSummary) { + luckmailSummary.textContent = summary; + } +} + +function renderLuckmailPurchases(purchases = []) { + if (!luckmailList || !luckmailSummary) return; + + lastRenderedLuckmailPurchases = Array.isArray(purchases) ? purchases : []; + pruneLuckmailSelection(lastRenderedLuckmailPurchases); + luckmailList.innerHTML = ''; + + if (!lastRenderedLuckmailPurchases.length) { + luckmailSelectedPurchaseIds.clear(); + luckmailList.innerHTML = '<div class="luckmail-empty">未找到 openai 项目的 LuckMail 邮箱。</div>'; + luckmailSummary.textContent = '加载已购邮箱后可在这里管理 openai 项目的 LuckMail 邮箱。'; + if (btnLuckmailDisableUsed) btnLuckmailDisableUsed.disabled = true; + updateLuckmailBulkUI([]); + return; + } + + const usedCount = lastRenderedLuckmailPurchases.filter((purchase) => purchase.used).length; + const reusableCount = lastRenderedLuckmailPurchases.filter((purchase) => purchase.reusable).length; + const disableUsedCount = lastRenderedLuckmailPurchases.filter((purchase) => purchase.used && !purchase.preserved && !purchase.disabled).length; + luckmailSummary.textContent = `已加载 ${lastRenderedLuckmailPurchases.length} 个 openai 邮箱,其中 ${reusableCount} 个可复用,${usedCount} 个已本地标记为已用。`; + if (btnLuckmailDisableUsed) { + btnLuckmailDisableUsed.textContent = `禁用已用${disableUsedCount > 0 ? `(${disableUsedCount})` : ''}`; + btnLuckmailDisableUsed.disabled = disableUsedCount === 0; + } + + const visiblePurchases = getFilteredLuckmailPurchases(lastRenderedLuckmailPurchases); + if (!visiblePurchases.length) { + luckmailList.innerHTML = '<div class="luckmail-empty">没有匹配当前筛选条件的 LuckMail 邮箱。</div>'; + updateLuckmailBulkUI([]); + return; + } + + for (const purchase of visiblePurchases) { + const purchaseId = String(purchase.id); + const item = document.createElement('div'); + item.className = `luckmail-item${purchase.current ? ' is-current' : ''}`; + item.innerHTML = ` + <input class="luckmail-item-check" type="checkbox" data-action="select" ${luckmailSelectedPurchaseIds.has(purchaseId) ? 'checked' : ''} /> + <div class="luckmail-item-main"> + <div class="luckmail-item-email-row"> + <div class="luckmail-item-email">${escapeHtml(purchase.email_address || '(未知邮箱)')}</div> + <button + class="hotmail-copy-btn" + type="button" + data-action="copy-email" + title="复制邮箱" + aria-label="复制邮箱 ${escapeHtml(purchase.email_address || '')}" + >${COPY_ICON}</button> + </div> + <div class="luckmail-item-meta"> + <span class="luckmail-tag">${escapeHtml(normalizeLuckmailProjectName(purchase.project_name) || 'openai')}</span> + ${purchase.reusable ? '<span class="luckmail-tag active">可复用</span>' : ''} + ${purchase.current ? '<span class="luckmail-tag current">当前</span>' : ''} + ${purchase.used ? '<span class="luckmail-tag used">已用</span>' : ''} + ${purchase.preserved ? '<span class="luckmail-tag">保留</span>' : ''} + ${purchase.disabled ? '<span class="luckmail-tag disabled">已禁用</span>' : ''} + ${purchase.tag_name && normalizeLuckmailSearchText(purchase.tag_name) !== normalizeLuckmailSearchText(getLuckmailPreserveTagName()) + ? `<span class="luckmail-tag">${escapeHtml(purchase.tag_name)}</span>` + : ''} + </div> + <div class="luckmail-item-details"> + <span>ID:${escapeHtml(String(purchase.id || ''))}</span> + <span>保修至:${escapeHtml(formatLuckmailDateTime(purchase.warranty_until))}</span> + </div> + </div> + <div class="luckmail-item-actions"> + <button class="btn btn-outline btn-xs" type="button" data-action="use">使用此邮箱</button> + <button class="btn btn-outline btn-xs" type="button" data-action="toggle-used">${escapeHtml(purchase.used ? '标记未用' : '标记已用')}</button> + <button class="btn btn-outline btn-xs" type="button" data-action="toggle-preserved">${escapeHtml(purchase.preserved ? '取消保留' : '保留')}</button> + <button class="btn btn-outline btn-xs" type="button" data-action="toggle-disabled">${escapeHtml(purchase.disabled ? '启用' : '禁用')}</button> + </div> + `; + + item.querySelector('[data-action="select"]').addEventListener('change', (event) => { + if (event.target.checked) { + luckmailSelectedPurchaseIds.add(purchaseId); + } else { + luckmailSelectedPurchaseIds.delete(purchaseId); + } + updateLuckmailBulkUI(visiblePurchases); + }); + item.querySelector('[data-action="copy-email"]').addEventListener('click', async () => { + await copyTextToClipboard(purchase.email_address || ''); + showToast('邮箱已复制', 'success', 1600); + }); + item.querySelector('[data-action="use"]').addEventListener('click', async () => { + await selectSingleLuckmailPurchase(purchase); + }); + item.querySelector('[data-action="toggle-used"]').addEventListener('click', async () => { + await setSingleLuckmailPurchaseUsedState(purchase, !purchase.used); + }); + item.querySelector('[data-action="toggle-preserved"]').addEventListener('click', async () => { + await setSingleLuckmailPurchasePreservedState(purchase, !purchase.preserved); + }); + item.querySelector('[data-action="toggle-disabled"]').addEventListener('click', async () => { + await setSingleLuckmailPurchaseDisabledState(purchase, !purchase.disabled); + }); + luckmailList.appendChild(item); + } + + updateLuckmailBulkUI(visiblePurchases); +} + +async function refreshLuckmailPurchases(options = {}) { + const { silent = false } = options; + if (!luckmailSection || luckmailSection.style.display === 'none') { + return; + } + + if (!silent) setLuckmailLoadingState(true, '正在加载 LuckMail openai 邮箱...'); + try { + const response = await chrome.runtime.sendMessage({ + type: 'LIST_LUCKMAIL_PURCHASES', + source: 'sidepanel', + payload: {}, + }); + if (response?.error) throw new Error(response.error); + renderLuckmailPurchases(response?.purchases || []); + } catch (err) { + luckmailSelectedPurchaseIds.clear(); + if (luckmailList) { + luckmailList.innerHTML = '<div class="luckmail-empty">无法加载 LuckMail 邮箱列表。</div>'; + } + if (luckmailSummary) { + luckmailSummary.textContent = err.message; + } + updateLuckmailBulkUI([]); + if (!silent) { + showToast(`LuckMail 邮箱列表加载失败:${err.message}`, 'error'); + } + } finally { + setLuckmailLoadingState(false); + } +} + +function queueLuckmailPurchaseRefresh() { + if (luckmailRefreshQueued) return; + luckmailRefreshQueued = true; + setTimeout(async () => { + luckmailRefreshQueued = false; + await refreshLuckmailPurchases({ silent: true }); + }, 150); +} + +async function selectSingleLuckmailPurchase(purchase) { + setLuckmailLoadingState(true, `正在切换到 ${purchase.email_address} ...`); + try { + const response = await chrome.runtime.sendMessage({ + type: 'SELECT_LUCKMAIL_PURCHASE', + source: 'sidepanel', + payload: { purchaseId: purchase.id }, + }); + if (response?.error) throw new Error(response.error); + inputEmail.value = response?.purchase?.email_address || purchase.email_address || ''; + showToast(`已切换当前 LuckMail 邮箱为 ${purchase.email_address}`, 'success', 2200); + await refreshLuckmailPurchases({ silent: true }); + } catch (err) { + if (luckmailSummary) luckmailSummary.textContent = err.message; + showToast(`切换 LuckMail 邮箱失败:${err.message}`, 'error'); + } finally { + setLuckmailLoadingState(false); + } +} + +async function setSingleLuckmailPurchaseUsedState(purchase, used) { + setLuckmailLoadingState(true, `正在更新 ${purchase.email_address} 的已用状态...`); + try { + const response = await chrome.runtime.sendMessage({ + type: 'SET_LUCKMAIL_PURCHASE_USED_STATE', + source: 'sidepanel', + payload: { purchaseId: purchase.id, used }, + }); + if (response?.error) throw new Error(response.error); + showToast(`${purchase.email_address} 已${used ? '标记为已用' : '恢复为未用'}`, 'success', 2200); + await refreshLuckmailPurchases({ silent: true }); + } catch (err) { + if (luckmailSummary) luckmailSummary.textContent = err.message; + showToast(`更新 LuckMail 已用状态失败:${err.message}`, 'error'); + } finally { + setLuckmailLoadingState(false); + } +} + +async function setSingleLuckmailPurchasePreservedState(purchase, preserved) { + setLuckmailLoadingState(true, `正在更新 ${purchase.email_address} 的保留状态...`); + try { + const response = await chrome.runtime.sendMessage({ + type: 'SET_LUCKMAIL_PURCHASE_PRESERVED_STATE', + source: 'sidepanel', + payload: { purchaseId: purchase.id, preserved }, + }); + if (response?.error) throw new Error(response.error); + showToast(`${purchase.email_address} 已${preserved ? '设为保留' : '取消保留'}`, 'success', 2200); + await refreshLuckmailPurchases({ silent: true }); + } catch (err) { + if (luckmailSummary) luckmailSummary.textContent = err.message; + showToast(`更新 LuckMail 保留状态失败:${err.message}`, 'error'); + } finally { + setLuckmailLoadingState(false); + } +} + +async function setSingleLuckmailPurchaseDisabledState(purchase, disabled) { + setLuckmailLoadingState(true, `正在${disabled ? '禁用' : '启用'} ${purchase.email_address} ...`); + try { + const response = await chrome.runtime.sendMessage({ + type: 'SET_LUCKMAIL_PURCHASE_DISABLED_STATE', + source: 'sidepanel', + payload: { purchaseId: purchase.id, disabled }, + }); + if (response?.error) throw new Error(response.error); + showToast(`${purchase.email_address} 已${disabled ? '禁用' : '启用'}`, 'success', 2200); + await refreshLuckmailPurchases({ silent: true }); + } catch (err) { + if (luckmailSummary) luckmailSummary.textContent = err.message; + showToast(`更新 LuckMail 禁用状态失败:${err.message}`, 'error'); + } finally { + setLuckmailLoadingState(false); + } +} + +async function runBulkLuckmailAction(action) { + const selectedIds = lastRenderedLuckmailPurchases + .filter((purchase) => luckmailSelectedPurchaseIds.has(String(purchase.id))) + .map((purchase) => purchase.id); + if (!selectedIds.length) { + updateLuckmailBulkUI(); + return; + } + + const actionLabelMap = { + used: '标记已用', + unused: '标记未用', + preserve: '保留', + unpreserve: '取消保留', + disable: '禁用', + enable: '启用', + }; + + setLuckmailLoadingState(true, `正在批量${actionLabelMap[action] || '处理'} LuckMail 邮箱...`); + try { + const response = await chrome.runtime.sendMessage({ + type: 'BATCH_UPDATE_LUCKMAIL_PURCHASES', + source: 'sidepanel', + payload: { action, ids: selectedIds }, + }); + if (response?.error) throw new Error(response.error); + showToast(`已批量${actionLabelMap[action] || '处理'} ${selectedIds.length} 个 LuckMail 邮箱`, 'success', 2400); + await refreshLuckmailPurchases({ silent: true }); + } catch (err) { + if (luckmailSummary) luckmailSummary.textContent = err.message; + showToast(`批量处理 LuckMail 邮箱失败:${err.message}`, 'error'); + } finally { + setLuckmailLoadingState(false); + updateLuckmailBulkUI(); + } +} + +async function disableUsedLuckmailPurchases() { + const confirmed = await openConfirmModal({ + title: '禁用已用 LuckMail 邮箱', + message: '确认禁用所有本地已用且未保留的 openai LuckMail 邮箱吗?', + confirmLabel: '确认禁用', + confirmVariant: 'btn-danger', + }); + if (!confirmed) { + return; + } + + setLuckmailLoadingState(true, '正在禁用已用 LuckMail 邮箱...'); + try { + const response = await chrome.runtime.sendMessage({ + type: 'DISABLE_USED_LUCKMAIL_PURCHASES', + source: 'sidepanel', + payload: {}, + }); + if (response?.error) throw new Error(response.error); + const disabledCount = Array.isArray(response?.disabledIds) ? response.disabledIds.length : 0; + showToast(`已禁用 ${disabledCount} 个 LuckMail 邮箱`, disabledCount > 0 ? 'success' : 'info', 2400); + await refreshLuckmailPurchases({ silent: true }); + } catch (err) { + if (luckmailSummary) luckmailSummary.textContent = err.message; + showToast(`禁用已用 LuckMail 邮箱失败:${err.message}`, 'error'); + } finally { + setLuckmailLoadingState(false); + } +} + function getMailProviderLoginConfig(provider = selectMailProvider.value) { return MAIL_PROVIDER_LOGIN_CONFIGS[String(provider || '').trim()] || null; } @@ -1572,9 +2275,24 @@ function isCurrentEmailManagedByHotmail(state = latestState) { return inputEmailValue === hotmailEmail || stateEmailValue === hotmailEmail; } -function isCurrentEmailManagedByGeneratedAlias(provider = latestState?.mailProvider, state = latestState) { +function isCurrentEmailManagedByLuckmail(state = latestState) { + const luckmailEmail = getCurrentLuckmailEmail(state); + if (!luckmailEmail) { + return false; + } + + const inputEmailValue = String(inputEmail.value || '').trim(); + const stateEmailValue = String(state?.email || '').trim(); + return inputEmailValue === luckmailEmail || stateEmailValue === luckmailEmail; +} + +function isCurrentEmailManagedByGeneratedAlias( + provider = latestState?.mailProvider, + state = latestState, + mail2925Mode = latestState?.mail2925Mode +) { const normalizedProvider = String(provider || '').trim(); - if (!usesGeneratedAliasMailProvider(normalizedProvider)) { + if (!(normalizedProvider === '2925' && normalizeMail2925Mode(mail2925Mode) === MAIL_2925_MODE_PROVIDE)) { return false; } @@ -1596,6 +2314,7 @@ function updateMailLoginButtonState() { const config = getMailProviderLoginConfig(); const loginUrl = getMailProviderLoginUrl(); btnMailLogin.disabled = !loginUrl; + btnMailLogin.textContent = config?.buttonLabel || '登录'; btnMailLogin.title = loginUrl ? `打开 ${config.label} 登录页` : '当前邮箱服务没有可跳转的登录页'; } @@ -1808,21 +2527,42 @@ function renderHotmailAccounts() { function updateMailProviderUI() { const use2925 = selectMailProvider.value === '2925'; - const useGeneratedAlias = usesGeneratedAliasMailProvider(selectMailProvider.value); + const mail2925Mode = getSelectedMail2925Mode(); + const useGeneratedAlias = use2925 && mail2925Mode === MAIL_2925_MODE_PROVIDE; const useInbucket = selectMailProvider.value === 'inbucket'; const useHotmail = selectMailProvider.value === 'hotmail-api'; + const useLuckmail = isLuckmailProvider(); const useCustomEmail = isCustomMailProvider(); - const useEmailGenerator = !useHotmail && !useGeneratedAlias && !useCustomEmail; + const useEmailGenerator = !useHotmail && !useLuckmail && !useGeneratedAlias && !useCustomEmail; + const useCloudflareTempEmailProvider = selectMailProvider.value === 'cloudflare-temp-email'; updateMailLoginButtonState(); + if (rowMail2925Mode) { + rowMail2925Mode.style.display = use2925 ? '' : 'none'; + } rowEmailPrefix.style.display = useGeneratedAlias ? '' : 'none'; const hotmailServiceMode = getSelectedHotmailServiceMode(); rowInbucketHost.style.display = useInbucket ? '' : 'none'; rowInbucketMailbox.style.display = useInbucket ? '' : 'none'; - const useCloudflare = selectEmailGenerator.value === 'cloudflare'; + const selectedGenerator = getSelectedEmailGenerator(); + const useCloudflare = selectedGenerator === 'cloudflare'; + const useIcloud = selectedGenerator === 'icloud'; + const useCloudflareTempEmailGenerator = selectedGenerator === 'cloudflare-temp-email'; const showCloudflareDomain = useEmailGenerator && useCloudflare; + const showCloudflareTempEmailSettings = useCloudflareTempEmailProvider || (useEmailGenerator && useCloudflareTempEmailGenerator); + const showCloudflareTempEmailDomain = useEmailGenerator && useCloudflareTempEmailGenerator; if (rowEmailGenerator) { rowEmailGenerator.style.display = useEmailGenerator ? '' : 'none'; } + if (icloudSection) { + const showIcloudSection = useEmailGenerator && useIcloud; + icloudSection.style.display = showIcloudSection ? '' : 'none'; + if (showIcloudSection && !lastRenderedIcloudAliases.length) { + queueIcloudAliasRefresh(); + } + if (!showIcloudSection) { + hideIcloudLoginHelp(); + } + } rowCfDomain.style.display = showCloudflareDomain ? '' : 'none'; const { domains } = getCloudflareDomainsFromState(); if (showCloudflareDomain) { @@ -1830,13 +2570,26 @@ function updateMailProviderUI() { } else { setCloudflareDomainEditMode(false, { clearInput: false }); } + rowTempEmailBaseUrl.style.display = showCloudflareTempEmailSettings ? '' : 'none'; + rowTempEmailAdminAuth.style.display = showCloudflareTempEmailSettings ? '' : 'none'; + rowTempEmailCustomAuth.style.display = showCloudflareTempEmailSettings ? '' : 'none'; + rowTempEmailDomain.style.display = showCloudflareTempEmailDomain ? '' : 'none'; + const { domains: tempEmailDomains } = getCloudflareTempEmailDomainsFromState(); + if (showCloudflareTempEmailDomain) { + setCloudflareTempEmailDomainEditMode(cloudflareTempEmailDomainEditMode || tempEmailDomains.length === 0, { clearInput: false }); + } else { + setCloudflareTempEmailDomainEditMode(false, { clearInput: false }); + } if (hotmailSection) { hotmailSection.style.display = useHotmail ? '' : 'none'; } + if (luckmailSection) { + luckmailSection.style.display = useLuckmail ? '' : 'none'; + } labelEmailPrefix.textContent = '邮箱前缀'; inputEmailPrefix.placeholder = '例如 abc'; - selectEmailGenerator.disabled = useHotmail || useGeneratedAlias || useCustomEmail; + selectEmailGenerator.disabled = useHotmail || useLuckmail || useGeneratedAlias || useCustomEmail; if (rowHotmailServiceMode) { rowHotmailServiceMode.style.display = useHotmail ? '' : 'none'; } @@ -1846,27 +2599,36 @@ function updateMailProviderUI() { if (rowHotmailLocalBaseUrl) { rowHotmailLocalBaseUrl.style.display = useHotmail && hotmailServiceMode === HOTMAIL_SERVICE_MODE_LOCAL ? '' : 'none'; } - btnFetchEmail.hidden = useHotmail || useCustomEmail; - inputEmail.readOnly = useHotmail || useGeneratedAlias; + btnFetchEmail.hidden = useHotmail || useLuckmail || useCustomEmail; + inputEmail.readOnly = useHotmail || useLuckmail || useGeneratedAlias; const uiCopy = useCustomEmail ? getCustomMailProviderUiCopy() : getEmailGeneratorUiCopy(); inputEmail.placeholder = useHotmail ? '由 Hotmail 账号池自动分配' - : (use2925 ? '步骤 3 自动生成 2925 邮箱并回填' : uiCopy.placeholder); - btnFetchEmail.disabled = useGeneratedAlias || useCustomEmail || isAutoRunLockedPhase(); + : (useLuckmail + ? '步骤 3 自动购买 LuckMail 邮箱并回填' + : (useGeneratedAlias ? '步骤 3 自动生成 2925 邮箱并回填' : uiCopy.placeholder)); + btnFetchEmail.disabled = useGeneratedAlias || useLuckmail || useCustomEmail || isAutoRunLockedPhase(); if (!btnFetchEmail.disabled) { btnFetchEmail.textContent = uiCopy.buttonLabel; } if (autoHintText) { autoHintText.textContent = useHotmail ? '请先校验并选择一个 Hotmail 账号' + : (useLuckmail + ? '步骤 3 会自动购买 LuckMail 邮箱并用于收码' : (useGeneratedAlias ? '步骤 3 会自动生成邮箱,无需手动获取' - : (useCustomEmail ? '请先填写自定义注册邮箱,成功一轮后会自动清空' : '先自动获取邮箱,或手动粘贴邮箱后再继续')); + : (useCustomEmail ? '请先填写自定义注册邮箱,成功一轮后会自动清空' : `先自动获取${uiCopy.label},或手动粘贴邮箱后再继续`))); } if (useHotmail) { inputEmail.value = getCurrentHotmailEmail(); + } else if (useLuckmail) { + inputEmail.value = getCurrentLuckmailEmail(); } renderHotmailAccounts(); + if (useLuckmail) { + renderLuckmailPurchases(lastRenderedLuckmailPurchases); + } } async function saveCloudflareDomainSettings(domains, activeDomain, options = {}) { @@ -1901,6 +2663,38 @@ async function saveCloudflareDomainSettings(domains, activeDomain, options = {}) } } +async function saveCloudflareTempEmailDomainSettings(domains, activeDomain, options = {}) { + const { silent = false } = options; + const normalizedDomains = normalizeCloudflareTempEmailDomains(domains); + const normalizedActiveDomain = normalizeCloudflareTempEmailDomainValue(activeDomain) || normalizedDomains[0] || ''; + const payload = { + cloudflareTempEmailDomain: normalizedActiveDomain, + cloudflareTempEmailDomains: normalizedDomains, + }; + + const response = await chrome.runtime.sendMessage({ + type: 'SAVE_SETTING', + source: 'sidepanel', + payload, + }); + + if (response?.error) { + throw new Error(response.error); + } + + syncLatestState({ + ...payload, + }); + renderCloudflareTempEmailDomainOptions(normalizedActiveDomain); + setCloudflareTempEmailDomainEditMode(false, { clearInput: true }); + markSettingsDirty(false); + updateMailProviderUI(); + + if (!silent) { + showToast('Cloudflare Temp Email 域名已保存', 'success', 1800); + } +} + function updatePanelModeUI() { const useSub2Api = selectPanelMode.value === 'sub2api'; rowVpsUrl.style.display = useSub2Api ? 'none' : ''; @@ -1993,6 +2787,11 @@ function updateButtonStates() { }); btnReset.disabled = anyRunning || autoScheduled || isAutoRunPausedPhase() || autoLocked; + const disableIcloudControls = anyRunning || autoScheduled || autoLocked; + if (btnIcloudRefresh) btnIcloudRefresh.disabled = disableIcloudControls; + if (btnIcloudDeleteUsed) btnIcloudDeleteUsed.disabled = disableIcloudControls || !(lastRenderedIcloudAliases.some((alias) => alias.used && !alias.preserved)); + if (selectIcloudHostPreference) selectIcloudHostPreference.disabled = disableIcloudControls; + if (checkboxAutoDeleteIcloud) checkboxAutoDeleteIcloud.disabled = disableIcloudControls; updateStopButtonState(anyRunning || autoScheduled || isAutoRunPausedPhase() || autoLocked); } @@ -2115,6 +2914,375 @@ function escapeHtml(text) { return div.innerHTML; } +function normalizeIcloudSearchText(value) { + return String(value || '').trim().toLowerCase(); +} + +function getFilteredIcloudAliases(aliases = lastRenderedIcloudAliases) { + const searchTerm = normalizeIcloudSearchText(icloudSearchTerm); + return (Array.isArray(aliases) ? aliases : []).filter((alias) => { + const matchesFilter = (() => { + switch (icloudFilterMode) { + case 'active': return Boolean(alias.active); + case 'used': return Boolean(alias.used); + case 'unused': return !alias.used; + case 'preserved': return Boolean(alias.preserved); + default: return true; + } + })(); + + if (!matchesFilter) return false; + if (!searchTerm) return true; + + const haystack = [ + alias.email, + alias.label, + alias.note, + alias.used ? '已用 used' : '未用 unused', + alias.active ? '可用 active' : '不可用 inactive', + alias.preserved ? '保留 preserved' : '', + ].join(' ').toLowerCase(); + + return haystack.includes(searchTerm); + }); +} + +function pruneIcloudSelection(aliases = lastRenderedIcloudAliases) { + const existing = new Set((Array.isArray(aliases) ? aliases : []).map((alias) => alias.email)); + icloudSelectedEmails = new Set([...icloudSelectedEmails].filter((email) => existing.has(email))); +} + +function updateIcloudBulkUI(visibleAliases = getFilteredIcloudAliases()) { + if (!checkboxIcloudSelectAll || !icloudSelectionSummary) { + return; + } + + const visibleEmails = visibleAliases.map((alias) => alias.email); + const selectedVisibleCount = visibleEmails.filter((email) => icloudSelectedEmails.has(email)).length; + const hasVisible = visibleEmails.length > 0; + + checkboxIcloudSelectAll.checked = hasVisible && selectedVisibleCount === visibleEmails.length; + checkboxIcloudSelectAll.indeterminate = selectedVisibleCount > 0 && selectedVisibleCount < visibleEmails.length; + checkboxIcloudSelectAll.disabled = !hasVisible; + icloudSelectionSummary.textContent = `已选 ${icloudSelectedEmails.size} 个(当前显示 ${visibleEmails.length} 个)`; + + const hasSelection = icloudSelectedEmails.size > 0; + if (btnIcloudBulkUsed) btnIcloudBulkUsed.disabled = !hasSelection; + if (btnIcloudBulkUnused) btnIcloudBulkUnused.disabled = !hasSelection; + if (btnIcloudBulkPreserve) btnIcloudBulkPreserve.disabled = !hasSelection; + if (btnIcloudBulkUnpreserve) btnIcloudBulkUnpreserve.disabled = !hasSelection; + if (btnIcloudBulkDelete) btnIcloudBulkDelete.disabled = !hasSelection; +} + +function setIcloudLoadingState(loading, summary = '') { + if (btnIcloudRefresh) btnIcloudRefresh.disabled = loading; + if (btnIcloudDeleteUsed) btnIcloudDeleteUsed.disabled = loading; + if (btnIcloudLoginDone) btnIcloudLoginDone.disabled = loading; + if (inputIcloudSearch) inputIcloudSearch.disabled = loading; + if (selectIcloudFilter) selectIcloudFilter.disabled = loading; + if (checkboxIcloudSelectAll) checkboxIcloudSelectAll.disabled = loading || getFilteredIcloudAliases().length === 0; + if (btnIcloudBulkUsed) btnIcloudBulkUsed.disabled = loading || icloudSelectedEmails.size === 0; + if (btnIcloudBulkUnused) btnIcloudBulkUnused.disabled = loading || icloudSelectedEmails.size === 0; + if (btnIcloudBulkPreserve) btnIcloudBulkPreserve.disabled = loading || icloudSelectedEmails.size === 0; + if (btnIcloudBulkUnpreserve) btnIcloudBulkUnpreserve.disabled = loading || icloudSelectedEmails.size === 0; + if (btnIcloudBulkDelete) btnIcloudBulkDelete.disabled = loading || icloudSelectedEmails.size === 0; + if (summary && icloudSummary) icloudSummary.textContent = summary; +} + +function showIcloudLoginHelp(payload = {}) { + if (!icloudLoginHelp) return; + const loginUrl = String(payload.loginUrl || '').trim(); + const host = loginUrl ? new URL(loginUrl).host : 'icloud.com.cn / icloud.com'; + if (icloudLoginHelpTitle) icloudLoginHelpTitle.textContent = '需要登录 iCloud'; + if (icloudLoginHelpText) icloudLoginHelpText.textContent = `我已经为你打开 ${host}。请在那个页面完成登录,然后回到这里点击“我已登录”。`; + icloudLoginHelp.style.display = 'flex'; +} + +function hideIcloudLoginHelp() { + if (icloudLoginHelp) { + icloudLoginHelp.style.display = 'none'; + } +} + +function renderIcloudAliases(aliases = []) { + if (!icloudList || !icloudSummary) return; + + lastRenderedIcloudAliases = Array.isArray(aliases) ? aliases : []; + pruneIcloudSelection(lastRenderedIcloudAliases); + icloudList.innerHTML = ''; + + if (!aliases.length) { + icloudSelectedEmails.clear(); + icloudList.innerHTML = '<div class="icloud-empty">未找到 iCloud Hide My Email 别名。</div>'; + icloudSummary.textContent = '加载你的 iCloud Hide My Email 别名以便在这里管理。'; + if (btnIcloudDeleteUsed) btnIcloudDeleteUsed.disabled = true; + updateIcloudBulkUI([]); + return; + } + + const usedCount = aliases.filter((alias) => alias.used).length; + const deletableUsedCount = aliases.filter((alias) => alias.used && !alias.preserved).length; + icloudSummary.textContent = `已加载 ${aliases.length} 个别名,其中 ${usedCount} 个已标记为已用。`; + if (btnIcloudDeleteUsed) btnIcloudDeleteUsed.disabled = deletableUsedCount === 0; + + const visibleAliases = getFilteredIcloudAliases(aliases); + if (!visibleAliases.length) { + icloudList.innerHTML = '<div class="icloud-empty">没有匹配当前筛选条件的别名。</div>'; + updateIcloudBulkUI([]); + return; + } + + for (const alias of visibleAliases) { + const item = document.createElement('div'); + item.className = 'icloud-item'; + item.innerHTML = ` + <input class="icloud-item-check" type="checkbox" data-action="select" ${icloudSelectedEmails.has(alias.email) ? 'checked' : ''} /> + <div class="icloud-item-main"> + <div class="icloud-item-email">${escapeHtml(alias.email)}</div> + <div class="icloud-item-meta"> + ${alias.used ? '<span class="icloud-tag used">已用</span>' : ''} + ${!alias.used && alias.active ? '<span class="icloud-tag active">可用</span>' : ''} + ${alias.preserved ? '<span class="icloud-tag">保留</span>' : ''} + ${alias.label ? `<span class="icloud-tag">${escapeHtml(alias.label)}</span>` : ''} + ${alias.note ? `<span class="icloud-tag">${escapeHtml(alias.note)}</span>` : ''} + </div> + </div> + <div class="icloud-item-actions"> + <button class="btn btn-outline btn-xs" type="button" data-action="toggle-used">${escapeHtml(alias.used ? '标记未用' : '标记已用')}</button> + <button class="btn btn-outline btn-xs" type="button" data-action="toggle-preserved">${escapeHtml(alias.preserved ? '取消保留' : '保留')}</button> + <button class="btn btn-outline btn-xs" type="button" data-action="delete">删除</button> + </div> + `; + + item.querySelector('[data-action="select"]').addEventListener('change', (event) => { + if (event.target.checked) { + icloudSelectedEmails.add(alias.email); + } else { + icloudSelectedEmails.delete(alias.email); + } + updateIcloudBulkUI(visibleAliases); + }); + item.querySelector('[data-action="toggle-used"]').addEventListener('click', async () => { + await setSingleIcloudAliasUsedState(alias, !alias.used); + }); + item.querySelector('[data-action="toggle-preserved"]').addEventListener('click', async () => { + await setSingleIcloudAliasPreservedState(alias, !alias.preserved); + }); + item.querySelector('[data-action="delete"]').addEventListener('click', async () => { + await deleteSingleIcloudAlias(alias); + }); + icloudList.appendChild(item); + } + + updateIcloudBulkUI(visibleAliases); +} + +async function refreshIcloudAliases(options = {}) { + const { silent = false } = options; + if (!icloudSection || icloudSection.style.display === 'none') { + return; + } + + if (!silent) setIcloudLoadingState(true, '正在加载 iCloud 别名...'); + try { + const response = await chrome.runtime.sendMessage({ + type: 'LIST_ICLOUD_ALIASES', + source: 'sidepanel', + payload: {}, + }); + if (response?.error) throw new Error(response.error); + hideIcloudLoginHelp(); + renderIcloudAliases(response?.aliases || []); + } catch (err) { + icloudSelectedEmails.clear(); + if (icloudList) { + icloudList.innerHTML = '<div class="icloud-empty">无法加载 iCloud 别名。</div>'; + } + if (icloudSummary) { + icloudSummary.textContent = err.message; + } + updateIcloudBulkUI([]); + if (!silent) showToast(`iCloud 别名加载失败:${err.message}`, 'error'); + } finally { + setIcloudLoadingState(false); + } +} + +function queueIcloudAliasRefresh() { + if (icloudRefreshQueued) return; + icloudRefreshQueued = true; + setTimeout(async () => { + icloudRefreshQueued = false; + await refreshIcloudAliases({ silent: true }); + }, 150); +} + +async function deleteSingleIcloudAlias(alias) { + const confirmed = await openConfirmModal({ + title: '删除 iCloud 别名', + message: `确认删除 ${alias.email} 吗?此操作不可撤销。`, + confirmLabel: '确认删除', + confirmVariant: 'btn-danger', + }); + if (!confirmed) { + return; + } + + setIcloudLoadingState(true, `正在删除 ${alias.email} ...`); + try { + const response = await chrome.runtime.sendMessage({ + type: 'DELETE_ICLOUD_ALIAS', + source: 'sidepanel', + payload: { email: alias.email, anonymousId: alias.anonymousId }, + }); + if (response?.error) throw new Error(response.error); + showToast(`已删除 ${alias.email}`, 'success', 2200); + await refreshIcloudAliases({ silent: true }); + } catch (err) { + if (icloudSummary) icloudSummary.textContent = err.message; + showToast(`删除 iCloud 别名失败:${err.message}`, 'error'); + } finally { + setIcloudLoadingState(false); + } +} + +async function setSingleIcloudAliasUsedState(alias, used) { + setIcloudLoadingState(true, `正在更新 ${alias.email} 的使用状态...`); + try { + const response = await chrome.runtime.sendMessage({ + type: 'SET_ICLOUD_ALIAS_USED_STATE', + source: 'sidepanel', + payload: { email: alias.email, used }, + }); + if (response?.error) throw new Error(response.error); + showToast(`${alias.email} 已${used ? '标记为已用' : '恢复为未用'}`, 'success', 2200); + await refreshIcloudAliases({ silent: true }); + } catch (err) { + if (icloudSummary) icloudSummary.textContent = err.message; + showToast(`更新 iCloud 使用状态失败:${err.message}`, 'error'); + } finally { + setIcloudLoadingState(false); + } +} + +async function setSingleIcloudAliasPreservedState(alias, preserved) { + setIcloudLoadingState(true, `正在更新 ${alias.email} 的保留状态...`); + try { + const response = await chrome.runtime.sendMessage({ + type: 'SET_ICLOUD_ALIAS_PRESERVED_STATE', + source: 'sidepanel', + payload: { email: alias.email, preserved }, + }); + if (response?.error) throw new Error(response.error); + showToast(`${alias.email} 已${preserved ? '设为保留' : '取消保留'}`, 'success', 2200); + await refreshIcloudAliases({ silent: true }); + } catch (err) { + if (icloudSummary) icloudSummary.textContent = err.message; + showToast(`更新 iCloud 保留状态失败:${err.message}`, 'error'); + } finally { + setIcloudLoadingState(false); + } +} + +async function runBulkIcloudAction(action) { + const selectedAliases = lastRenderedIcloudAliases.filter((alias) => icloudSelectedEmails.has(alias.email)); + if (!selectedAliases.length) { + updateIcloudBulkUI(); + return; + } + + if (action === 'delete') { + const confirmed = await openConfirmModal({ + title: '批量删除 iCloud 别名', + message: `确认删除选中的 ${selectedAliases.length} 个 iCloud 别名吗?此操作不可撤销。`, + confirmLabel: '确认删除', + confirmVariant: 'btn-danger', + }); + if (!confirmed) { + return; + } + } + + const actionLabelMap = { + used: '标记已用', + unused: '标记未用', + preserve: '保留', + unpreserve: '取消保留', + delete: '删除', + }; + setIcloudLoadingState(true, `正在批量${actionLabelMap[action] || '处理'} iCloud 别名...`); + + try { + for (const alias of selectedAliases) { + let response = null; + if (action === 'used' || action === 'unused') { + response = await chrome.runtime.sendMessage({ + type: 'SET_ICLOUD_ALIAS_USED_STATE', + source: 'sidepanel', + payload: { email: alias.email, used: action === 'used' }, + }); + } else if (action === 'preserve' || action === 'unpreserve') { + response = await chrome.runtime.sendMessage({ + type: 'SET_ICLOUD_ALIAS_PRESERVED_STATE', + source: 'sidepanel', + payload: { email: alias.email, preserved: action === 'preserve' }, + }); + } else if (action === 'delete') { + response = await chrome.runtime.sendMessage({ + type: 'DELETE_ICLOUD_ALIAS', + source: 'sidepanel', + payload: { email: alias.email, anonymousId: alias.anonymousId }, + }); + icloudSelectedEmails.delete(alias.email); + } + + if (response?.error) { + throw new Error(response.error); + } + } + + showToast(`已批量${actionLabelMap[action] || '处理'} ${selectedAliases.length} 个 iCloud 别名`, 'success', 2400); + await refreshIcloudAliases({ silent: true }); + } catch (err) { + if (icloudSummary) icloudSummary.textContent = err.message; + showToast(`批量处理 iCloud 别名失败:${err.message}`, 'error'); + } finally { + setIcloudLoadingState(false); + updateIcloudBulkUI(); + } +} + +async function deleteUsedIcloudAliases() { + const confirmed = await openConfirmModal({ + title: '删除已用 iCloud 别名', + message: '确认删除所有未保留的已用 iCloud 别名吗?此操作不可撤销。', + confirmLabel: '确认删除', + confirmVariant: 'btn-danger', + }); + if (!confirmed) { + return; + } + + setIcloudLoadingState(true, '正在删除已用 iCloud 别名...'); + try { + const response = await chrome.runtime.sendMessage({ + type: 'DELETE_USED_ICLOUD_ALIASES', + source: 'sidepanel', + payload: {}, + }); + if (response?.error) throw new Error(response.error); + const deleted = response?.deleted || []; + const skipped = response?.skipped || []; + showToast(`已删除 ${deleted.length} 个已用别名,跳过 ${skipped.length} 个`, skipped.length ? 'warn' : 'success', 2800); + await refreshIcloudAliases({ silent: true }); + } catch (err) { + if (icloudSummary) icloudSummary.textContent = err.message; + showToast(`删除已用 iCloud 别名失败:${err.message}`, 'error'); + } finally { + setIcloudLoadingState(false); + } +} + async function fetchGeneratedEmail(options = {}) { const { showFailureToast = true } = options; const uiCopy = getEmailGeneratorUiCopy(); @@ -2143,6 +3311,9 @@ async function fetchGeneratedEmail(options = {}) { } inputEmail.value = response.email; + if (getSelectedEmailGenerator() === 'icloud') { + queueIcloudAliasRefresh(); + } showToast(`已${uiCopy.successVerb} ${uiCopy.label}:${response.email}`, 'success', 2500); return response.email; } catch (err) { @@ -2402,7 +3573,7 @@ document.querySelectorAll('.step-btn').forEach(btn => { syncLatestState({ customPassword: inputPassword.value }); } let email = inputEmail.value.trim(); - if (selectMailProvider.value === 'hotmail-api') { + if (selectMailProvider.value === 'hotmail-api' || isLuckmailProvider()) { const response = await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } }); if (response?.error) { throw new Error(response.error); @@ -2449,12 +3620,81 @@ document.querySelectorAll('.step-btn').forEach(btn => { }); btnFetchEmail.addEventListener('click', async () => { - if (selectMailProvider.value === 'hotmail-api' || isCustomMailProvider()) { + if (selectMailProvider.value === 'hotmail-api' || isLuckmailProvider() || isCustomMailProvider()) { return; } await fetchGeneratedEmail().catch(() => { }); }); +btnIcloudRefresh?.addEventListener('click', async () => { + await refreshIcloudAliases(); +}); + +btnIcloudDeleteUsed?.addEventListener('click', async () => { + await deleteUsedIcloudAliases(); +}); + +inputIcloudSearch?.addEventListener('input', () => { + icloudSearchTerm = inputIcloudSearch.value || ''; + renderIcloudAliases(lastRenderedIcloudAliases); +}); + +selectIcloudFilter?.addEventListener('change', () => { + icloudFilterMode = selectIcloudFilter.value || 'all'; + renderIcloudAliases(lastRenderedIcloudAliases); +}); + +checkboxIcloudSelectAll?.addEventListener('change', () => { + const visibleAliases = getFilteredIcloudAliases(); + if (checkboxIcloudSelectAll.checked) { + visibleAliases.forEach((alias) => icloudSelectedEmails.add(alias.email)); + } else { + visibleAliases.forEach((alias) => icloudSelectedEmails.delete(alias.email)); + } + renderIcloudAliases(lastRenderedIcloudAliases); +}); + +btnIcloudBulkUsed?.addEventListener('click', async () => { + await runBulkIcloudAction('used'); +}); + +btnIcloudBulkUnused?.addEventListener('click', async () => { + await runBulkIcloudAction('unused'); +}); + +btnIcloudBulkPreserve?.addEventListener('click', async () => { + await runBulkIcloudAction('preserve'); +}); + +btnIcloudBulkUnpreserve?.addEventListener('click', async () => { + await runBulkIcloudAction('unpreserve'); +}); + +btnIcloudBulkDelete?.addEventListener('click', async () => { + await runBulkIcloudAction('delete'); +}); + +btnIcloudLoginDone?.addEventListener('click', async () => { + btnIcloudLoginDone.disabled = true; + try { + const response = await chrome.runtime.sendMessage({ + type: 'CHECK_ICLOUD_SESSION', + source: 'sidepanel', + payload: {}, + }); + if (response?.error) { + throw new Error(response.error); + } + hideIcloudLoginHelp(); + showToast('iCloud 会话已恢复,别名列表已刷新。', 'success', 2600); + await refreshIcloudAliases({ silent: true }); + } catch (err) { + showToast(`看起来还没有登录完成:${err.message}`, 'warn', 4200); + } finally { + btnIcloudLoginDone.disabled = false; + } +}); + btnToggleHotmailList?.addEventListener('click', () => { setHotmailListExpanded(!hotmailListExpanded); }); @@ -2913,7 +4153,13 @@ btnReset.addEventListener('click', async () => { } await chrome.runtime.sendMessage({ type: 'RESET', source: 'sidepanel' }); - syncLatestState({ stepStatuses: STEP_DEFAULT_STATUSES, currentHotmailAccountId: null, email: null }); + syncLatestState({ + stepStatuses: STEP_DEFAULT_STATUSES, + currentHotmailAccountId: null, + currentLuckmailPurchase: null, + currentLuckmailMailCursor: null, + email: null, + }); syncAutoRunState({ autoRunning: false, autoRunPhase: 'idle', @@ -2933,6 +4179,15 @@ btnReset.addEventListener('click', async () => { displayStatus.textContent = '就绪'; statusBar.className = 'status-bar'; logArea.innerHTML = ''; + icloudSelectedEmails.clear(); + lastRenderedIcloudAliases = []; + if (icloudList) { + icloudList.innerHTML = ''; + } + if (icloudSummary) { + icloudSummary.textContent = '加载你的 iCloud Hide My Email 别名以便在这里管理。'; + } + updateIcloudBulkUI([]); document.querySelectorAll('.step-row').forEach(row => row.className = 'step-row'); document.querySelectorAll('.step-status').forEach(el => el.textContent = ''); setDefaultAutoRunButton(); @@ -2942,6 +4197,10 @@ btnReset.addEventListener('click', async () => { updateButtonStates(); updateProgressCounter(); renderHotmailAccounts(); + renderLuckmailPurchases(lastRenderedLuckmailPurchases); + if (isLuckmailProvider()) { + queueLuckmailPurchaseRefresh(); + } }); // Clear log @@ -2951,7 +4210,7 @@ btnClearLog.addEventListener('click', () => { // Save settings on change inputEmail.addEventListener('change', async () => { - if (selectMailProvider.value === 'hotmail-api') { + if (selectMailProvider.value === 'hotmail-api' || isLuckmailProvider()) { return; } const email = inputEmail.value.trim(); @@ -2996,6 +4255,73 @@ inputVpsPassword.addEventListener('blur', () => { }); }); +[inputLuckmailApiKey, inputLuckmailBaseUrl, inputLuckmailDomain].forEach((input) => { + input?.addEventListener('input', () => { + markSettingsDirty(true); + scheduleSettingsAutoSave(); + }); + input?.addEventListener('blur', () => { + saveSettings({ silent: true }).catch(() => { }); + }); +}); + +selectLuckmailEmailType?.addEventListener('change', () => { + markSettingsDirty(true); + saveSettings({ silent: true }).catch(() => { }); +}); + +inputLuckmailSearch?.addEventListener('input', (event) => { + luckmailSearchTerm = event.target.value || ''; + renderLuckmailPurchases(lastRenderedLuckmailPurchases); +}); + +selectLuckmailFilter?.addEventListener('change', (event) => { + luckmailFilterMode = String(event.target.value || 'all').trim() || 'all'; + renderLuckmailPurchases(lastRenderedLuckmailPurchases); +}); + +checkboxLuckmailSelectAll?.addEventListener('change', () => { + const visiblePurchases = getFilteredLuckmailPurchases(); + if (checkboxLuckmailSelectAll.checked) { + visiblePurchases.forEach((purchase) => luckmailSelectedPurchaseIds.add(String(purchase.id))); + } else { + visiblePurchases.forEach((purchase) => luckmailSelectedPurchaseIds.delete(String(purchase.id))); + } + renderLuckmailPurchases(lastRenderedLuckmailPurchases); +}); + +btnLuckmailRefresh?.addEventListener('click', async () => { + await refreshLuckmailPurchases(); +}); + +btnLuckmailDisableUsed?.addEventListener('click', async () => { + await disableUsedLuckmailPurchases(); +}); + +btnLuckmailBulkUsed?.addEventListener('click', async () => { + await runBulkLuckmailAction('used'); +}); + +btnLuckmailBulkUnused?.addEventListener('click', async () => { + await runBulkLuckmailAction('unused'); +}); + +btnLuckmailBulkPreserve?.addEventListener('click', async () => { + await runBulkLuckmailAction('preserve'); +}); + +btnLuckmailBulkUnpreserve?.addEventListener('click', async () => { + await runBulkLuckmailAction('unpreserve'); +}); + +btnLuckmailBulkDisable?.addEventListener('click', async () => { + await runBulkLuckmailAction('disable'); +}); + +btnLuckmailBulkEnable?.addEventListener('click', async () => { + await runBulkLuckmailAction('enable'); +}); + inputPassword.addEventListener('input', () => { markSettingsDirty(true); updateButtonStates(); @@ -3007,21 +4333,55 @@ inputPassword.addEventListener('blur', () => { selectMailProvider.addEventListener('change', async () => { const previousProvider = latestState?.mailProvider || ''; + const previousMail2925Mode = latestState?.mail2925Mode; const nextProvider = selectMailProvider.value; updateMailProviderUI(); const leavingHotmail = previousProvider === 'hotmail-api' && nextProvider !== 'hotmail-api' && isCurrentEmailManagedByHotmail(); - const leavingGeneratedAlias = previousProvider !== nextProvider - && usesGeneratedAliasMailProvider(previousProvider) - && isCurrentEmailManagedByGeneratedAlias(previousProvider); - if (leavingHotmail || leavingGeneratedAlias) { + const leavingLuckmail = previousProvider === LUCKMAIL_PROVIDER + && nextProvider !== LUCKMAIL_PROVIDER + && isCurrentEmailManagedByLuckmail(); + const leavingGeneratedAlias = ( + previousProvider !== nextProvider + || (previousProvider === '2925' && normalizeMail2925Mode(previousMail2925Mode) !== getSelectedMail2925Mode()) + ) && previousProvider === '2925' + && normalizeMail2925Mode(previousMail2925Mode) === MAIL_2925_MODE_PROVIDE + && isCurrentEmailManagedByGeneratedAlias(previousProvider, latestState, previousMail2925Mode); + if (leavingHotmail || leavingLuckmail || leavingGeneratedAlias) { await clearRegistrationEmail({ silent: true }).catch(() => { }); } + if (nextProvider === LUCKMAIL_PROVIDER) { + queueLuckmailPurchaseRefresh(); + } markSettingsDirty(true); saveSettings({ silent: true }).catch(() => { }); }); +mail2925ModeButtons.forEach((button) => { + button.addEventListener('click', async () => { + const nextMode = normalizeMail2925Mode(button.dataset.mail2925Mode); + const previousMode = normalizeMail2925Mode(latestState?.mail2925Mode); + if (nextMode === getSelectedMail2925Mode()) { + return; + } + + setMail2925Mode(nextMode); + updateMailProviderUI(); + + const leavingGeneratedAlias = selectMailProvider.value === '2925' + && previousMode === MAIL_2925_MODE_PROVIDE + && nextMode !== MAIL_2925_MODE_PROVIDE + && isCurrentEmailManagedByGeneratedAlias('2925', latestState, previousMode); + if (leavingGeneratedAlias) { + await clearRegistrationEmail({ silent: true }).catch(() => { }); + } + + markSettingsDirty(true); + saveSettings({ silent: true }).catch(() => { }); + }); +}); + selectEmailGenerator.addEventListener('change', () => { updateMailProviderUI(); clearRegistrationEmail({ silent: true }).catch(() => { }); @@ -3029,6 +4389,19 @@ selectEmailGenerator.addEventListener('change', () => { saveSettings({ silent: true }).catch(() => { }); }); +selectIcloudHostPreference?.addEventListener('change', () => { + markSettingsDirty(true); + saveSettings({ silent: true }).catch(() => { }); + if (getSelectedEmailGenerator() === 'icloud') { + queueIcloudAliasRefresh(); + } +}); + +checkboxAutoDeleteIcloud?.addEventListener('change', () => { + markSettingsDirty(true); + saveSettings({ silent: true }).catch(() => { }); +}); + selectPanelMode.addEventListener('change', () => { updatePanelModeUI(); markSettingsDirty(true); @@ -3043,6 +4416,14 @@ selectCfDomain.addEventListener('change', () => { saveSettings({ silent: true }).catch(() => { }); }); +selectTempEmailDomain.addEventListener('change', () => { + if (selectTempEmailDomain.disabled) { + return; + } + markSettingsDirty(true); + saveSettings({ silent: true }).catch(() => { }); +}); + btnCfDomainMode.addEventListener('click', async () => { try { if (!cloudflareDomainEditMode) { @@ -3064,6 +4445,27 @@ btnCfDomainMode.addEventListener('click', async () => { } }); +btnTempEmailDomainMode.addEventListener('click', async () => { + try { + if (!cloudflareTempEmailDomainEditMode) { + setCloudflareTempEmailDomainEditMode(true, { clearInput: true }); + return; + } + + const newDomain = normalizeCloudflareTempEmailDomainValue(inputTempEmailDomain.value); + if (!newDomain) { + showToast('请输入有效的 Cloudflare Temp Email 域名。', 'warn'); + inputTempEmailDomain.focus(); + return; + } + + const { domains } = getCloudflareTempEmailDomainsFromState(); + await saveCloudflareTempEmailDomainSettings([...domains, newDomain], newDomain); + } catch (err) { + showToast(err.message, 'error'); + } +}); + inputCfDomain.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); @@ -3071,6 +4473,13 @@ inputCfDomain.addEventListener('keydown', (event) => { } }); +inputTempEmailDomain.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + btnTempEmailDomainMode.click(); + } +}); + inputSub2ApiUrl.addEventListener('input', () => { markSettingsDirty(true); scheduleSettingsAutoSave(); @@ -3152,6 +4561,31 @@ inputAutoSkipFailures.addEventListener('change', async () => { saveSettings({ silent: true }).catch(() => { }); }); +inputTempEmailBaseUrl.addEventListener('input', () => { + markSettingsDirty(true); + scheduleSettingsAutoSave(); +}); +inputTempEmailBaseUrl.addEventListener('blur', () => { + inputTempEmailBaseUrl.value = normalizeCloudflareTempEmailBaseUrlValue(inputTempEmailBaseUrl.value); + saveSettings({ silent: true }).catch(() => { }); +}); + +inputTempEmailAdminAuth.addEventListener('input', () => { + markSettingsDirty(true); + scheduleSettingsAutoSave(); +}); +inputTempEmailAdminAuth.addEventListener('blur', () => { + saveSettings({ silent: true }).catch(() => { }); +}); + +inputTempEmailCustomAuth.addEventListener('input', () => { + markSettingsDirty(true); + scheduleSettingsAutoSave(); +}); +inputTempEmailCustomAuth.addEventListener('blur', () => { + saveSettings({ silent: true }).catch(() => { }); +}); + inputAutoSkipFailuresThreadIntervalMinutes.addEventListener('input', () => { markSettingsDirty(true); scheduleSettingsAutoSave(); @@ -3268,6 +4702,11 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { displayStatus.textContent = '就绪'; statusBar.className = 'status-bar'; logArea.innerHTML = ''; + icloudSelectedEmails.clear(); + lastRenderedIcloudAliases = []; + if (icloudList) icloudList.innerHTML = ''; + if (icloudSummary) icloudSummary.textContent = '加载你的 iCloud Hide My Email 别名以便在这里管理。'; + updateIcloudBulkUI([]); document.querySelectorAll('.step-row').forEach(row => row.className = 'step-row'); document.querySelectorAll('.step-status').forEach(el => el.textContent = ''); syncAutoRunState({ @@ -3307,12 +4746,52 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { displayLocalhostUrl.textContent = message.payload.localhostUrl || '等待中...'; displayLocalhostUrl.classList.toggle('has-value', Boolean(message.payload.localhostUrl)); } + if (message.payload.cloudflareTempEmailBaseUrl !== undefined) { + inputTempEmailBaseUrl.value = message.payload.cloudflareTempEmailBaseUrl || ''; + } + if (message.payload.cloudflareTempEmailAdminAuth !== undefined) { + inputTempEmailAdminAuth.value = message.payload.cloudflareTempEmailAdminAuth || ''; + } + if (message.payload.cloudflareTempEmailCustomAuth !== undefined) { + inputTempEmailCustomAuth.value = message.payload.cloudflareTempEmailCustomAuth || ''; + } + if (message.payload.cloudflareTempEmailDomain !== undefined || message.payload.cloudflareTempEmailDomains !== undefined) { + renderCloudflareTempEmailDomainOptions(message.payload.cloudflareTempEmailDomain || latestState?.cloudflareTempEmailDomain || ''); + } if (message.payload.currentHotmailAccountId !== undefined || message.payload.hotmailAccounts !== undefined) { renderHotmailAccounts(); if (selectMailProvider.value === 'hotmail-api') { inputEmail.value = getCurrentHotmailEmail(); } } + if (message.payload.luckmailApiKey !== undefined) { + inputLuckmailApiKey.value = message.payload.luckmailApiKey || ''; + } + if (message.payload.luckmailBaseUrl !== undefined) { + inputLuckmailBaseUrl.value = normalizeLuckmailBaseUrl(message.payload.luckmailBaseUrl); + } + if (message.payload.luckmailEmailType !== undefined) { + selectLuckmailEmailType.value = normalizeLuckmailEmailType(message.payload.luckmailEmailType); + } + if (message.payload.luckmailDomain !== undefined) { + inputLuckmailDomain.value = message.payload.luckmailDomain || ''; + } + if (message.payload.luckmailUsedPurchases !== undefined && isLuckmailProvider()) { + queueLuckmailPurchaseRefresh(); + } + if (message.payload.currentLuckmailPurchase !== undefined && isLuckmailProvider()) { + inputEmail.value = getCurrentLuckmailEmail(); + queueLuckmailPurchaseRefresh(); + } + if (message.payload.autoDeleteUsedIcloudAlias !== undefined && checkboxAutoDeleteIcloud) { + checkboxAutoDeleteIcloud.checked = Boolean(message.payload.autoDeleteUsedIcloudAlias); + } + if (message.payload.icloudHostPreference !== undefined && selectIcloudHostPreference) { + const hostPreference = String(message.payload.icloudHostPreference || '').trim().toLowerCase(); + selectIcloudHostPreference.value = hostPreference === 'icloud.com' + ? 'icloud.com' + : (hostPreference === 'icloud.com.cn' ? 'icloud.com.cn' : 'auto'); + } if (message.payload.autoRunSkipFailures !== undefined) { inputAutoSkipFailures.checked = Boolean(message.payload.autoRunSkipFailures); updateFallbackThreadIntervalInputState(); @@ -3336,6 +4815,21 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { break; } + case 'ICLOUD_LOGIN_REQUIRED': { + const loginMessage = '需要登录 iCloud,我已经为你打开登录页。'; + showToast(loginMessage, 'warn', 5000); + if (icloudSummary) { + icloudSummary.textContent = loginMessage; + } + showIcloudLoginHelp(message.payload || {}); + break; + } + + case 'ICLOUD_ALIASES_CHANGED': { + queueIcloudAliasRefresh(); + break; + } + case 'AUTO_RUN_STATUS': { syncLatestState({ autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(message.payload.phase), @@ -3407,6 +4901,7 @@ initHotmailListExpandedState(); updateSaveButtonState(); updateConfigMenuControls(); setLocalCpaStep9Mode(DEFAULT_LOCAL_CPA_STEP9_MODE); +setMail2925Mode(DEFAULT_MAIL_2925_MODE); initializeReleaseInfo().catch((err) => { console.error('Failed to initialize release info:', err); }); diff --git a/tests/auto-run-fresh-attempt-reset.test.js b/tests/auto-run-fresh-attempt-reset.test.js index f7078af..91533ea 100644 --- a/tests/auto-run-fresh-attempt-reset.test.js +++ b/tests/auto-run-fresh-attempt-reset.test.js @@ -60,11 +60,20 @@ const bundle = [ extractFunction('hasSavedProgress'), extractFunction('getRunningSteps'), extractFunction('getAutoRunStatusPayload'), + extractFunction('createAutoRunRoundSummary'), + extractFunction('normalizeAutoRunRoundSummary'), + extractFunction('buildAutoRunRoundSummaries'), + extractFunction('serializeAutoRunRoundSummaries'), + extractFunction('getAutoRunRoundRetryCount'), + extractFunction('formatAutoRunFailureReasons'), + extractFunction('logAutoRunFinalSummary'), + extractFunction('waitBetweenAutoRunRounds'), extractFunction('autoRunLoop'), ].join('\n'); const api = new Function(` const STOP_ERROR_MESSAGE = 'Flow stopped.'; +const AUTO_RUN_MAX_RETRIES_PER_ROUND = 3; const DEFAULT_STATE = { stepStatuses: { 1: 'pending', @@ -180,6 +189,20 @@ function cancelPendingCommands() {} function normalizeAutoRunFallbackThreadIntervalMinutes(value) { return Math.max(0, Math.floor(Number(value) || 0)); } +function buildAutoRunRoundSummaries(totalRuns, rawSummaries = []) { + return Array.from({ length: totalRuns }, (_, index) => ({ + round: index + 1, + status: rawSummaries[index]?.status || 'pending', + attempts: rawSummaries[index]?.attempts || 0, + failureReasons: [...(rawSummaries[index]?.failureReasons || [])], + finalFailureReason: rawSummaries[index]?.finalFailureReason || '', + })); +} +function serializeAutoRunRoundSummaries(totalRuns, roundSummaries = []) { + return buildAutoRunRoundSummaries(totalRuns, roundSummaries); +} +async function logAutoRunFinalSummary() {} +async function waitBetweenAutoRunRounds() {} const chrome = { runtime: { diff --git a/tests/background-icloud.test.js b/tests/background-icloud.test.js new file mode 100644 index 0000000..c7f26db --- /dev/null +++ b/tests/background-icloud.test.js @@ -0,0 +1,265 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); + +const source = fs.readFileSync('background.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map((marker) => source.indexOf(marker)) + .find((index) => index >= 0); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let i = start; i < source.length; i += 1) { + const ch = source[i]; + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (ch === '{' && signatureEnded) { + braceStart = i; + break; + } + } + if (braceStart < 0) { + throw new Error(`missing body for function ${name}`); + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +const bundle = [ + extractFunction('normalizeEmailGenerator'), + extractFunction('getEmailGeneratorLabel'), + extractFunction('normalizePersistentSettingValue'), + extractFunction('finalizeIcloudAliasAfterSuccessfulFlow'), +].join('\n'); + +function createApi(overrides = {}) { + return new Function('overrides', ` +const HOTMAIL_PROVIDER = 'hotmail-api'; +const HOTMAIL_SERVICE_MODE_LOCAL = 'local'; +const CLOUDFLARE_TEMP_EMAIL_GENERATOR = 'cloudflare-temp-email'; +const DEFAULT_LOCAL_CPA_STEP9_MODE = 'submit'; +const DEFAULT_HOTMAIL_REMOTE_BASE_URL = ''; +const DEFAULT_HOTMAIL_LOCAL_BASE_URL = 'http://127.0.0.1:17373'; +const PERSISTED_SETTING_DEFAULTS = { + mailProvider: '163', + autoStepDelaySeconds: null, +}; + +const calls = { + setUsed: [], + logs: [], + deletes: [], + listCalls: 0, +}; + +function normalizeIcloudHost(value) { + const normalized = String(value || '').trim().toLowerCase(); + return ['icloud.com', 'icloud.com.cn'].includes(normalized) ? normalized : ''; +} +function normalizePanelMode(value = '') { + return String(value || '').trim().toLowerCase() === 'sub2api' ? 'sub2api' : 'cpa'; +} +function normalizeMailProvider(value = '') { + return String(value || '').trim().toLowerCase() || '163'; +} +function normalizeAutoRunFallbackThreadIntervalMinutes(value) { + return Math.max(0, Math.floor(Number(value) || 0)); +} +function normalizeAutoRunDelayMinutes(value) { + return Math.max(1, Math.floor(Number(value) || 30)); +} +function normalizeAutoStepDelaySeconds(value, fallback = null) { + const numeric = Number(value); + return Number.isFinite(numeric) ? Math.max(0, Math.floor(numeric)) : fallback; +} +function normalizeHotmailServiceMode() { + return HOTMAIL_SERVICE_MODE_LOCAL; +} +function normalizeHotmailRemoteBaseUrl(value = '') { + return String(value || '').trim() || DEFAULT_HOTMAIL_REMOTE_BASE_URL; +} +function normalizeHotmailLocalBaseUrl(value = '') { + return String(value || '').trim() || DEFAULT_HOTMAIL_LOCAL_BASE_URL; +} +function normalizeCloudflareDomain(value = '') { + return String(value || '').trim().toLowerCase(); +} +function normalizeCloudflareDomains(values = []) { + return Array.isArray(values) ? values : []; +} +function normalizeHotmailAccounts(values = []) { + return Array.isArray(values) ? values : []; +} +function getManualAliasUsageMap(state) { + return { ...(state?.manualAliasUsage || {}) }; +} +function getPreservedAliasMap(state) { + return { ...(state?.preservedAliases || {}) }; +} +function isAliasPreserved(state, email) { + return Boolean(getPreservedAliasMap(state)[String(email || '').trim().toLowerCase()]); +} +async function setIcloudAliasUsedState(payload, options = {}) { + calls.setUsed.push({ payload, options }); +} +async function addLog(message, level = 'info') { + calls.logs.push({ message, level }); +} +async function deleteIcloudAlias(alias) { + calls.deletes.push(alias); +} +async function listIcloudAliases() { + calls.listCalls += 1; + return overrides.listIcloudAliases ? overrides.listIcloudAliases() : []; +} +function findIcloudAliasByEmail(aliases, email) { + return (aliases || []).find((alias) => String(alias.email || '').toLowerCase() === String(email || '').toLowerCase()) || null; +} +function getErrorMessage(error) { + return String(typeof error === 'string' ? error : error?.message || ''); +} + +${bundle} + +return { + calls, + normalizeEmailGenerator, + getEmailGeneratorLabel, + normalizePersistentSettingValue, + finalizeIcloudAliasAfterSuccessfulFlow, +}; +`)(overrides); +} + +test('normalizeEmailGenerator and label support icloud', () => { + const api = createApi(); + assert.equal(api.normalizeEmailGenerator('icloud'), 'icloud'); + assert.equal(api.getEmailGeneratorLabel('icloud'), 'iCloud 隐私邮箱'); +}); + +test('normalizePersistentSettingValue handles icloud settings', () => { + const api = createApi(); + assert.equal(api.normalizePersistentSettingValue('icloudHostPreference', 'icloud.com'), 'icloud.com'); + assert.equal(api.normalizePersistentSettingValue('icloudHostPreference', 'bad-host'), 'auto'); + assert.equal(api.normalizePersistentSettingValue('autoDeleteUsedIcloudAlias', 1), true); +}); + +test('finalizeIcloudAliasAfterSuccessfulFlow marks icloud aliases as used without deleting when auto-delete is off', async () => { + const api = createApi(); + const result = await api.finalizeIcloudAliasAfterSuccessfulFlow({ + email: 'alias@icloud.com', + emailGenerator: 'icloud', + autoDeleteUsedIcloudAlias: false, + manualAliasUsage: {}, + preservedAliases: {}, + }); + + assert.deepEqual(result, { handled: true, deleted: false }); + assert.equal(api.calls.setUsed.length, 1); + assert.equal(api.calls.listCalls, 0); + assert.equal(api.calls.deletes.length, 0); +}); + +test('finalizeIcloudAliasAfterSuccessfulFlow skips deleting preserved aliases', async () => { + const api = createApi(); + const result = await api.finalizeIcloudAliasAfterSuccessfulFlow({ + email: 'alias@icloud.com', + emailGenerator: 'icloud', + autoDeleteUsedIcloudAlias: true, + manualAliasUsage: {}, + preservedAliases: { 'alias@icloud.com': true }, + }); + + assert.deepEqual(result, { handled: true, deleted: false }); + assert.equal(api.calls.setUsed.length, 1); + assert.equal(api.calls.listCalls, 0); + assert.equal(api.calls.deletes.length, 0); +}); + +test('finalizeIcloudAliasAfterSuccessfulFlow skips deleting aliases that are preserved in the latest alias list', async () => { + const api = createApi({ + listIcloudAliases() { + return [ + { email: 'alias@icloud.com', anonymousId: 'anon-1', preserved: true }, + ]; + }, + }); + + const result = await api.finalizeIcloudAliasAfterSuccessfulFlow({ + email: 'alias@icloud.com', + emailGenerator: 'icloud', + autoDeleteUsedIcloudAlias: true, + manualAliasUsage: {}, + preservedAliases: {}, + }); + + assert.deepEqual(result, { handled: true, deleted: false }); + assert.equal(api.calls.setUsed.length, 1); + assert.equal(api.calls.listCalls, 1); + assert.equal(api.calls.deletes.length, 0); +}); + +test('finalizeIcloudAliasAfterSuccessfulFlow deletes alias when auto-delete is enabled and alias exists', async () => { + const api = createApi({ + listIcloudAliases() { + return [ + { email: 'alias@icloud.com', anonymousId: 'anon-1', preserved: false }, + ]; + }, + }); + + const result = await api.finalizeIcloudAliasAfterSuccessfulFlow({ + email: 'alias@icloud.com', + emailGenerator: 'icloud', + autoDeleteUsedIcloudAlias: true, + manualAliasUsage: {}, + preservedAliases: {}, + }); + + assert.deepEqual(result, { handled: true, deleted: true }); + assert.equal(api.calls.setUsed.length, 1); + assert.equal(api.calls.listCalls, 1); + assert.deepEqual(api.calls.deletes, [ + { email: 'alias@icloud.com', anonymousId: 'anon-1', preserved: false }, + ]); +}); + +test('finalizeIcloudAliasAfterSuccessfulFlow ignores non-icloud flows', async () => { + const api = createApi(); + const result = await api.finalizeIcloudAliasAfterSuccessfulFlow({ + email: 'plain@example.com', + emailGenerator: 'duck', + autoDeleteUsedIcloudAlias: true, + manualAliasUsage: {}, + preservedAliases: {}, + }); + + assert.deepEqual(result, { handled: false, deleted: false }); + assert.equal(api.calls.setUsed.length, 0); +}); diff --git a/tests/background-luckmail.test.js b/tests/background-luckmail.test.js new file mode 100644 index 0000000..ff46891 --- /dev/null +++ b/tests/background-luckmail.test.js @@ -0,0 +1,572 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); + +const source = fs.readFileSync('background.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map((marker) => source.indexOf(marker)) + .find((index) => index >= 0); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let i = start; i < source.length; i += 1) { + const ch = source[i]; + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (ch === '{' && signatureEnded) { + braceStart = i; + break; + } + } + + if (braceStart < 0) { + throw new Error(`missing body for function ${name}`); + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +test('ensureLuckmailPurchaseForFlow buys openai mailbox and defaults email type to ms_graph', async () => { + const bundle = [ + extractFunction('getLuckmailSessionConfig'), + extractFunction('getCurrentLuckmailPurchase'), + extractFunction('ensureLuckmailPurchaseForFlow'), + ].join('\n'); + + const factory = new Function('initialState', ` +let currentState = { ...initialState }; +const DEFAULT_LUCKMAIL_PROJECT_CODE = 'openai'; +const purchaseCalls = []; +const activateCalls = []; + +function normalizeLuckmailBaseUrl(value) { + return String(value || '').trim() || 'https://mails.luckyous.com'; +} +function normalizeLuckmailEmailType(value) { + return ['self_built', 'ms_imap', 'ms_graph', 'google_variant'].includes(String(value || '').trim()) + ? String(value || '').trim() + : 'ms_graph'; +} +function normalizeLuckmailPurchase(value) { + return value; +} +function normalizeLuckmailPurchases(value) { + return value.purchases || []; +} +async function getState() { + return currentState; +} +function createLuckmailClient() { + return { + user: { + async purchaseEmails(projectCode, quantity, options) { + purchaseCalls.push({ projectCode, quantity, options }); + return { + purchases: [{ id: 15, email_address: 'demo@outlook.com', token: 'tok-1' }], + }; + }, + }, + }; +} +async function findReusableLuckmailPurchaseForFlow() { + return null; +} +async function activateLuckmailPurchaseForFlow(state, client, purchase, options) { + activateCalls.push({ state, purchase, options }); + currentState.currentLuckmailPurchase = purchase; + currentState.email = purchase.email_address; + return purchase; +} + +${bundle} + +return { + ensureLuckmailPurchaseForFlow, + snapshot() { + return { currentState, purchaseCalls, activateCalls }; + }, +}; +`); + + const api = factory({ + luckmailApiKey: 'sk-test', + luckmailBaseUrl: '', + luckmailEmailType: '', + luckmailDomain: '', + currentLuckmailPurchase: null, + email: null, + }); + + const purchase = await api.ensureLuckmailPurchaseForFlow(); + const snapshot = api.snapshot(); + + assert.equal(purchase.email_address, 'demo@outlook.com'); + assert.deepStrictEqual(snapshot.purchaseCalls, [{ + projectCode: 'openai', + quantity: 1, + options: { + emailType: 'ms_graph', + domain: undefined, + }, + }]); + assert.equal(snapshot.activateCalls[0].options.initializeCursor, false); + assert.equal(snapshot.currentState.email, 'demo@outlook.com'); +}); + +test('ensureLuckmailPurchaseForFlow reuses reusable openai mailbox before buying a new one', async () => { + const bundle = [ + extractFunction('getLuckmailSessionConfig'), + extractFunction('getCurrentLuckmailPurchase'), + extractFunction('ensureLuckmailPurchaseForFlow'), + ].join('\n'); + + const factory = new Function('initialState', ` +let currentState = { ...initialState }; +const DEFAULT_LUCKMAIL_PROJECT_CODE = 'openai'; +const purchaseCalls = []; +const activateCalls = []; + +function normalizeLuckmailBaseUrl(value) { + return String(value || '').trim() || 'https://mails.luckyous.com'; +} +function normalizeLuckmailEmailType(value) { + return ['self_built', 'ms_imap', 'ms_graph', 'google_variant'].includes(String(value || '').trim()) + ? String(value || '').trim() + : 'ms_graph'; +} +function normalizeLuckmailPurchase(value) { + return value; +} +function normalizeLuckmailPurchases(value) { + return value.purchases || []; +} +async function getState() { + return currentState; +} +function createLuckmailClient() { + return { + user: { + async purchaseEmails(projectCode, quantity, options) { + purchaseCalls.push({ projectCode, quantity, options }); + return { purchases: [] }; + }, + }, + }; +} +async function findReusableLuckmailPurchaseForFlow() { + return { + id: 99, + email_address: 'reuse@outlook.com', + token: 'tok-reuse', + }; +} +async function activateLuckmailPurchaseForFlow(state, client, purchase, options) { + activateCalls.push({ state, purchase, options }); + currentState.currentLuckmailPurchase = purchase; + currentState.email = purchase.email_address; + return purchase; +} + +${bundle} + +return { + ensureLuckmailPurchaseForFlow, + snapshot() { + return { currentState, purchaseCalls, activateCalls }; + }, +}; +`); + + const api = factory({ + luckmailApiKey: 'sk-test', + luckmailBaseUrl: 'https://mails.luckyous.com', + luckmailEmailType: 'ms_imap', + luckmailDomain: 'outlook.com', + currentLuckmailPurchase: null, + email: null, + }); + + const purchase = await api.ensureLuckmailPurchaseForFlow(); + const snapshot = api.snapshot(); + + assert.equal(purchase.id, 99); + assert.deepStrictEqual(snapshot.purchaseCalls, []); + assert.equal(snapshot.activateCalls[0].options.initializeCursor, true); + assert.match(snapshot.activateCalls[0].options.logMessage, /已复用 openai 邮箱/); +}); + +test('activateLuckmailPurchaseForFlow builds baseline cursor from existing mails when reusing mailbox', async () => { + const bundle = extractFunction('activateLuckmailPurchaseForFlow'); + + const factory = new Function(` +let currentPurchase = null; +let currentCursor = null; +let currentEmail = null; +const buildCalls = []; + +function normalizeLuckmailPurchase(value) { + return value; +} +async function setLuckmailPurchaseState(value) { + currentPurchase = value; +} +async function setLuckmailMailCursorState(value) { + currentCursor = value; +} +async function setEmailState(value) { + currentEmail = value; +} +async function addLog() {} +function buildLuckmailBaselineCursor(mails) { + buildCalls.push(mails); + return { messageId: 'mail-new', receivedAt: '2026-04-14 13:32:05' }; +} + +${bundle} + +return { + activateLuckmailPurchaseForFlow, + snapshot() { + return { currentPurchase, currentCursor, currentEmail, buildCalls }; + }, +}; +`); + + const api = factory(); + const client = { + user: { + async getTokenMails() { + return { + mails: [ + { message_id: 'mail-old', received_at: '2026-04-14 13:31:15' }, + { message_id: 'mail-new', received_at: '2026-04-14 13:32:05' }, + ], + }; + }, + }, + }; + + await api.activateLuckmailPurchaseForFlow({}, client, { + id: 5, + email_address: 'reuse@outlook.com', + token: 'tok-reuse', + }, { + initializeCursor: true, + logMessage: 'reuse', + }); + + const snapshot = api.snapshot(); + assert.equal(snapshot.currentPurchase.id, 5); + assert.deepStrictEqual(snapshot.currentCursor, { + messageId: 'mail-new', + receivedAt: '2026-04-14 13:32:05', + }); + assert.equal(snapshot.currentEmail, 'reuse@outlook.com'); + assert.equal(snapshot.buildCalls.length, 1); +}); + +test('listLuckmailPurchasesByProject only keeps openai purchases', async () => { + const bundle = extractFunction('listLuckmailPurchasesByProject'); + + const factory = new Function(` +const DEFAULT_LUCKMAIL_PROJECT_CODE = 'openai'; +function normalizeLuckmailProjectName(value) { + return String(value || '').trim().toLowerCase(); +} +function isLuckmailPurchaseForProject(purchase, projectCode) { + return normalizeLuckmailProjectName(purchase.project_name || purchase.project) === normalizeLuckmailProjectName(projectCode); +} +async function getAllLuckmailPurchases() { + return [ + { id: 1, project_name: 'OpenAi' }, + { id: 2, project_name: 'other' }, + { id: 3, project: 'openai' }, + ]; +} + +${bundle} + +return { listLuckmailPurchasesByProject }; +`); + + const api = factory(); + const result = await api.listLuckmailPurchasesByProject({}, { projectCode: 'openai' }); + assert.deepStrictEqual(result.map((item) => item.id), [1, 3]); +}); + +test('disableUsedLuckmailPurchases only disables locally used and non-preserved openai mailboxes', async () => { + const bundle = extractFunction('disableUsedLuckmailPurchases'); + + const factory = new Function(` +let clearedOptions = null; +const disabledCalls = []; +const DEFAULT_LUCKMAIL_PROJECT_CODE = 'openai'; + +function normalizeLuckmailPurchaseId(value) { + const numeric = Number(value); + return Number.isFinite(numeric) && numeric > 0 ? String(Math.floor(numeric)) : ''; +} +async function ensureManualInteractionAllowed() { + return { + luckmailUsedPurchases: { 1: true, 2: true, 3: true }, + luckmailPreserveTagId: 9, + luckmailPreserveTagName: '保留', + mailProvider: 'luckmail-api', + }; +} +function getLuckmailUsedPurchases(state) { + return state.luckmailUsedPurchases; +} +function getLuckmailPreserveTagInfo(state) { + return { + id: state.luckmailPreserveTagId, + name: state.luckmailPreserveTagName, + }; +} +function isLuckmailPurchasePreserved(purchase, options) { + return purchase.tag_id === options.preserveTagId || purchase.tag_name === options.preserveTagName; +} +function createLuckmailClient() { + return { + user: { + async batchSetPurchaseDisabled(ids, disabled) { + disabledCalls.push({ ids, disabled }); + }, + }, + }; +} +async function listLuckmailPurchasesByProject() { + return [ + { id: 1, email_address: 'used-1@outlook.com', user_disabled: 0, tag_id: 0, tag_name: '' }, + { id: 2, email_address: 'preserved@outlook.com', user_disabled: 0, tag_id: 9, tag_name: '保留' }, + { id: 3, email_address: 'already-disabled@outlook.com', user_disabled: 1, tag_id: 0, tag_name: '' }, + { id: 4, email_address: 'unused@outlook.com', user_disabled: 0, tag_id: 0, tag_name: '' }, + ]; +} +async function getState() { + return { + currentLuckmailPurchase: { id: 1 }, + mailProvider: 'luckmail-api', + }; +} +function getCurrentLuckmailPurchase(state) { + return state.currentLuckmailPurchase; +} +function isLuckmailProvider(state) { + return state.mailProvider === 'luckmail-api'; +} +async function clearLuckmailRuntimeState(options) { + clearedOptions = options; +} +async function addLog() {} + +${bundle} + +return { + disableUsedLuckmailPurchases, + snapshot() { + return { disabledCalls, clearedOptions }; + }, +}; +`); + + const api = factory(); + const result = await api.disableUsedLuckmailPurchases(); + const snapshot = api.snapshot(); + + assert.deepStrictEqual(result.disabledIds, [1]); + assert.deepStrictEqual(snapshot.disabledCalls, [{ ids: [1], disabled: 1 }]); + assert.deepStrictEqual(snapshot.clearedOptions, { clearEmail: true }); +}); + +test('resetState preserves LuckMail session config, used map, and preserve tag cache while clearing runtime purchase state', async () => { + const bundle = extractFunction('resetState'); + + const factory = new Function([ + 'let cleared = false;', + 'let storedPayload = null;', + "const LOG_PREFIX = '[test]';", + "const DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME = '保留';", + 'const DEFAULT_STATE = {', + " luckmailApiKey: '',", + " luckmailBaseUrl: 'https://mails.luckyous.com',", + " luckmailEmailType: 'ms_graph',", + " luckmailDomain: '',", + ' luckmailUsedPurchases: {},', + ' luckmailPreserveTagId: 0,', + " luckmailPreserveTagName: '保留',", + " currentLuckmailPurchase: { token: 'stale' },", + " currentLuckmailMailCursor: { messageId: 'stale' },", + ' email: null,', + '};', + 'function normalizeLuckmailBaseUrl(value) {', + " const normalized = String(value || '').trim() || 'https://mails.luckyous.com';", + " return normalized.replace(/\\/$/, '');", + '}', + 'function normalizeLuckmailEmailType(value) {', + " return ['self_built', 'ms_imap', 'ms_graph', 'google_variant'].includes(String(value || '').trim())", + " ? String(value || '').trim()", + " : 'ms_graph';", + '}', + 'function normalizeLuckmailUsedPurchases(value) {', + ' return value || {};', + '}', + 'async function getPersistedSettings() {', + " return { mailProvider: '163' };", + '}', + 'async function getPersistedAliasState() {', + ' return {};', + '}', + 'const chrome = {', + ' storage: {', + ' session: {', + ' async get() {', + ' return {', + " seenCodes: ['seen-1'],", + " seenInbucketMailIds: ['mail-1'],", + " accounts: [{ email: 'saved@example.com' }],", + " tabRegistry: { foo: { tabId: 1 } },", + " sourceLastUrls: { foo: 'https://example.com' },", + " luckmailApiKey: 'sk-session',", + " luckmailBaseUrl: 'https://demo.example.com/',", + " luckmailEmailType: 'ms_imap',", + " luckmailDomain: 'outlook.com',", + " luckmailUsedPurchases: { 88: true },", + ' luckmailPreserveTagId: 9,', + " luckmailPreserveTagName: '保留',", + ' };', + ' },', + ' async clear() {', + ' cleared = true;', + ' },', + ' async set(payload) {', + ' storedPayload = payload;', + ' },', + ' },', + ' },', + '};', + bundle, + 'return {', + ' resetState,', + ' snapshot() {', + ' return { cleared, storedPayload };', + ' },', + '};', + ].join('\n')); + + const api = factory(); + await api.resetState(); + const snapshot = api.snapshot(); + + assert.equal(snapshot.cleared, true); + assert.equal(snapshot.storedPayload.luckmailApiKey, 'sk-session'); + assert.equal(snapshot.storedPayload.luckmailBaseUrl, 'https://demo.example.com'); + assert.equal(snapshot.storedPayload.luckmailEmailType, 'ms_imap'); + assert.equal(snapshot.storedPayload.luckmailDomain, 'outlook.com'); + assert.deepStrictEqual(snapshot.storedPayload.luckmailUsedPurchases, { 88: true }); + assert.equal(snapshot.storedPayload.luckmailPreserveTagId, 9); + assert.equal(snapshot.storedPayload.luckmailPreserveTagName, '保留'); + assert.equal(snapshot.storedPayload.currentLuckmailPurchase, null); + assert.equal(snapshot.storedPayload.currentLuckmailMailCursor, null); +}); + +test('handleStepData step 9 marks current LuckMail purchase as used and clears runtime state', async () => { + const bundle = extractFunction('handleStepData'); + + const factory = new Function(` +let clearedOptions = null; +let usedMarker = null; +const logs = []; + +async function closeLocalhostCallbackTabs() {} +async function getState() { + return { + mailProvider: 'luckmail-api', + currentHotmailAccountId: null, + currentLuckmailPurchase: { + id: 123, + email_address: 'demo@outlook.com', + }, + email: 'demo@outlook.com', + }; +} +function getCurrentLuckmailPurchase(state) { + return state.currentLuckmailPurchase; +} +function isHotmailProvider() { + return false; +} +async function patchHotmailAccount() {} +function isLuckmailProvider(state) { + return state.mailProvider === 'luckmail-api'; +} +async function setLuckmailPurchaseUsedState(purchaseId, used) { + usedMarker = { purchaseId, used }; +} +async function clearLuckmailRuntimeState(options) { + clearedOptions = options; +} +async function addLog(message, level) { + logs.push({ message, level }); +} +function buildLocalhostCleanupPrefix() { + return ''; +} +async function closeTabsByUrlPrefix() {} +function shouldUseCustomRegistrationEmail() { + return false; +} +async function setEmailStateSilently() {} +async function setState() {} +function broadcastDataUpdate() {} +function isLocalhostOAuthCallbackUrl() { + return true; +} +async function finalizeIcloudAliasAfterSuccessfulFlow() {} + +${bundle} + +return { + handleStepData, + snapshot() { + return { clearedOptions, usedMarker, logs }; + }, +}; +`); + + const api = factory(); + await api.handleStepData(9, { + localhostUrl: 'http://localhost:1455/auth/callback?code=abc&state=xyz', + }); + + const snapshot = api.snapshot(); + assert.deepStrictEqual(snapshot.usedMarker, { purchaseId: 123, used: true }); + assert.deepStrictEqual(snapshot.clearedOptions, { clearEmail: true }); + assert.equal(snapshot.logs.at(-1).message, '当前 LuckMail 邮箱运行态已清空,下轮将优先复用未用邮箱或重新购买邮箱。'); +}); diff --git a/tests/cloudflare-temp-email-provider.test.js b/tests/cloudflare-temp-email-provider.test.js new file mode 100644 index 0000000..cc8a473 --- /dev/null +++ b/tests/cloudflare-temp-email-provider.test.js @@ -0,0 +1,160 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); + +const source = fs.readFileSync('background.js', 'utf8'); + +function extractFunction(name) { + const markers = [`async function ${name}(`, `function ${name}(`]; + const start = markers + .map((marker) => source.indexOf(marker)) + .find((index) => index >= 0); + if (start < 0) { + throw new Error(`missing function ${name}`); + } + + let parenDepth = 0; + let signatureEnded = false; + let braceStart = -1; + for (let i = start; i < source.length; i += 1) { + const ch = source[i]; + if (ch === '(') { + parenDepth += 1; + } else if (ch === ')') { + parenDepth -= 1; + if (parenDepth === 0) { + signatureEnded = true; + } + } else if (ch === '{' && signatureEnded) { + braceStart = i; + break; + } + } + if (braceStart < 0) { + throw new Error(`missing body for function ${name}`); + } + + let depth = 0; + let end = braceStart; + for (; end < source.length; end += 1) { + const ch = source[end]; + if (ch === '{') depth += 1; + if (ch === '}') { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + + return source.slice(start, end); +} + +test('pollCloudflareTempEmailVerificationCode returns code even if delete fails', async () => { + const bundle = [ + extractFunction('isStopError'), + extractFunction('throwIfStopped'), + extractFunction('summarizeCloudflareTempEmailMessagesForLog'), + extractFunction('pollCloudflareTempEmailVerificationCode'), + ].join('\n'); + +const api = new Function(` +let stopRequested = false; +const STOP_ERROR_MESSAGE = '流程已被用户停止。'; +const CLOUDFLARE_TEMP_EMAIL_DEFAULT_PAGE_SIZE = 20; +const logs = []; +function normalizeCloudflareTempEmailAddress(value) { + return String(value || '').trim().toLowerCase(); +} + +async function addLog(message, level) { + logs.push({ message, level }); +} +async function sleepWithStop() {} +async function listCloudflareTempEmailMessages() { + return { + config: {}, + messages: [{ + id: 'mail-1', + address: 'user@example.com', + receivedDateTime: '2026-04-13T09:20:00.000Z', + subject: 'OpenAI verification code', + from: { emailAddress: { address: 'noreply@tm.openai.com' } }, + bodyPreview: 'Your verification code is 123456.', + }], + }; +} +function pickVerificationMessageWithTimeFallback(messages) { + return { + match: { + code: '123456', + receivedAt: Date.parse(messages[0].receivedDateTime), + message: messages[0], + }, + usedRelaxedFilters: false, + usedTimeFallback: false, + }; +} +async function deleteCloudflareTempEmailMail() { + throw new Error('delete failed'); +} + +${bundle} + +return { + pollCloudflareTempEmailVerificationCode, + snapshot() { + return { logs }; + }, +}; +`)(); + + const result = await api.pollCloudflareTempEmailVerificationCode(4, { email: 'user@example.com' }, { + targetEmail: 'user@example.com', + maxAttempts: 1, + intervalMs: 1, + }); + + assert.equal(result.code, '123456'); + const state = api.snapshot(); + assert.equal(state.logs.some((entry) => entry.message.includes('删除 Cloudflare Temp Email 邮件失败')), true); +}); + +test('pollCloudflareTempEmailVerificationCode requires target email', async () => { + const bundle = [ + extractFunction('isStopError'), + extractFunction('throwIfStopped'), + extractFunction('pollCloudflareTempEmailVerificationCode'), + ].join('\n'); + + const api = new Function(` +let stopRequested = false; +const STOP_ERROR_MESSAGE = '流程已被用户停止。'; +const CLOUDFLARE_TEMP_EMAIL_DEFAULT_PAGE_SIZE = 20; +function normalizeCloudflareTempEmailAddress(value) { + return String(value || '').trim().toLowerCase(); +} +async function addLog() {} +async function sleepWithStop() {} +async function listCloudflareTempEmailMessages() { + throw new Error('should not reach list'); +} +function pickVerificationMessageWithTimeFallback() { + return { match: null, usedRelaxedFilters: false, usedTimeFallback: false }; +} +async function deleteCloudflareTempEmailMail() {} +function summarizeCloudflareTempEmailMessagesForLog() { + return ''; +} + +${bundle} + +return { pollCloudflareTempEmailVerificationCode }; +`)(); + + await assert.rejects( + api.pollCloudflareTempEmailVerificationCode(4, {}, {}), + /缺少目标邮箱地址/ + ); +}); diff --git a/tests/cloudflare-temp-email-utils.test.js b/tests/cloudflare-temp-email-utils.test.js new file mode 100644 index 0000000..cbf08a8 --- /dev/null +++ b/tests/cloudflare-temp-email-utils.test.js @@ -0,0 +1,107 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + buildCloudflareTempEmailHeaders, + getCloudflareTempEmailAddressFromResponse, + normalizeCloudflareTempEmailBaseUrl, + normalizeCloudflareTempEmailDomain, + normalizeCloudflareTempEmailDomains, + normalizeCloudflareTempEmailMailApiMessages, +} = require('../cloudflare-temp-email-utils.js'); + +test('normalizeCloudflareTempEmailBaseUrl normalizes host and preserves path', () => { + assert.equal( + normalizeCloudflareTempEmailBaseUrl('temp.example.com/api/'), + 'https://temp.example.com/api' + ); + assert.equal( + normalizeCloudflareTempEmailBaseUrl('http://127.0.0.1:8787'), + 'http://127.0.0.1:8787' + ); + assert.equal(normalizeCloudflareTempEmailBaseUrl('::::'), ''); +}); + +test('normalizeCloudflareTempEmailDomain and domains de-duplicate valid entries', () => { + assert.equal(normalizeCloudflareTempEmailDomain('@Mail.Example.com'), 'mail.example.com'); + assert.equal(normalizeCloudflareTempEmailDomain('not-a-domain'), ''); + assert.deepEqual( + normalizeCloudflareTempEmailDomains(['mail.example.com', 'MAIL.EXAMPLE.COM', 'bad-value']), + ['mail.example.com'] + ); +}); + +test('buildCloudflareTempEmailHeaders includes auth headers and content type when needed', () => { + assert.deepEqual( + buildCloudflareTempEmailHeaders( + { + adminAuth: 'admin-secret', + customAuth: 'site-secret', + }, + { json: true } + ), + { + 'x-admin-auth': 'admin-secret', + 'x-custom-auth': 'site-secret', + 'Content-Type': 'application/json', + Accept: 'application/json', + } + ); +}); + +test('normalizeCloudflareTempEmailMailApiMessages extracts sender, subject, code, and address from raw mime', () => { + const messages = normalizeCloudflareTempEmailMailApiMessages({ + data: [ + { + id: 'mail-1', + address: 'user@example.com', + created_at: '2026-04-13T09:15:00.000Z', + raw: [ + 'From: OpenAI <noreply@tm.openai.com>', + 'Subject: =?UTF-8?B?T3BlbkFJIHZlcmlmaWNhdGlvbiBjb2Rl?=', + 'Content-Type: text/plain; charset=UTF-8', + '', + 'Your verification code is 654321.', + ].join('\r\n'), + }, + ], + }); + + assert.equal(messages.length, 1); + assert.equal(messages[0].id, 'mail-1'); + assert.equal(messages[0].address, 'user@example.com'); + assert.equal(messages[0].subject, 'OpenAI verification code'); + assert.equal(messages[0].from.emailAddress.address, 'OpenAI <noreply@tm.openai.com>'); + assert.match(messages[0].bodyPreview, /654321/); +}); + +test('normalizeCloudflareTempEmailMailApiMessages decodes multipart quoted printable html bodies', () => { + const messages = normalizeCloudflareTempEmailMailApiMessages([ + { + id: 'mail-2', + address: 'user@example.com', + received_at: '2026-04-13T09:20:00.000Z', + source: [ + 'From: ChatGPT <noreply@tm.openai.com>', + 'Subject: Login code', + 'Content-Type: multipart/alternative; boundary="abc123"', + '', + '--abc123', + 'Content-Type: text/html; charset=UTF-8', + 'Content-Transfer-Encoding: quoted-printable', + '', + '<p>Your login code is <strong>112233</strong>.</p>', + '--abc123--', + ].join('\r\n'), + }, + ]); + + assert.equal(messages.length, 1); + assert.match(messages[0].bodyPreview, /112233/); + assert.equal(messages[0].subject, 'Login code'); +}); + +test('getCloudflareTempEmailAddressFromResponse supports direct and nested response shapes', () => { + assert.equal(getCloudflareTempEmailAddressFromResponse({ address: 'one@example.com' }), 'one@example.com'); + assert.equal(getCloudflareTempEmailAddressFromResponse({ data: { address: 'two@example.com' } }), 'two@example.com'); +}); diff --git a/tests/hotmail-api-mode.test.js b/tests/hotmail-api-mode.test.js index 281333b..8302747 100644 --- a/tests/hotmail-api-mode.test.js +++ b/tests/hotmail-api-mode.test.js @@ -26,6 +26,20 @@ test('Hotmail API对接应接入微软邮箱 helper 而不是旧远程服务占 const background = fs.readFileSync('background.js', 'utf8'); assert.match(background, /importScripts\([^)]*microsoft-email\.js[^)]*\)/, 'background 应加载微软邮箱 helper'); - assert.match(background, /fetchMicrosoftVerificationCode/, '步骤 4\/7 应接入微软验证码读取 helper'); assert.match(background, /fetchMicrosoftMailboxMessages/, '账号校验和最新验证码应接入微软邮箱列表 helper'); + assert.match( + background, + /fetchMicrosoftMailboxMessages\(\{[\s\S]*mailbox,[\s\S]*top:\s*10,/, + '微软邮箱 helper 应按请求的邮箱夹读取消息' + ); + assert.match( + background, + /for \(const mailbox of mailboxes\) \{\s*const result = await requestHotmailRemoteMailbox\(workingAccount, mailbox\);/s, + 'API对接模式不应丢掉 INBOX\/Junk 的逐邮箱夹轮询' + ); + assert.match( + background, + /pickVerificationMessageWithTimeFallback\(fetchResult\.messages, \{/, + '步骤 4\/7 应继续复用现有验证码筛选与时间回退逻辑' + ); }); diff --git a/tests/icloud-utils.test.js b/tests/icloud-utils.test.js new file mode 100644 index 0000000..5d8fa68 --- /dev/null +++ b/tests/icloud-utils.test.js @@ -0,0 +1,121 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + findIcloudAliasArray, + findIcloudAliasByEmail, + getConfiguredIcloudHostPreference, + getIcloudHostHintFromMessage, + getIcloudLoginUrlForHost, + getIcloudSetupUrlForHost, + normalizeBooleanMap, + normalizeIcloudAliasList, + normalizeIcloudAliasRecord, + normalizeIcloudHost, + pickReusableIcloudAlias, + toNormalizedEmailSet, +} = require('../icloud-utils.js'); + +test('normalizeIcloudHost and host preference helpers resolve supported hosts', () => { + assert.equal(normalizeIcloudHost('www.icloud.com'), 'icloud.com'); + assert.equal(normalizeIcloudHost('setup.icloud.com.cn'), 'icloud.com.cn'); + assert.equal(normalizeIcloudHost('example.com'), ''); + + assert.equal(getConfiguredIcloudHostPreference({ icloudHostPreference: 'icloud.com' }), 'icloud.com'); + assert.equal(getConfiguredIcloudHostPreference({ icloudHostPreference: 'auto' }), ''); + assert.equal(getIcloudLoginUrlForHost('icloud.com.cn'), 'https://www.icloud.com.cn/'); + assert.equal(getIcloudSetupUrlForHost('icloud.com'), 'https://setup.icloud.com/setup/ws/1'); +}); + +test('getIcloudHostHintFromMessage can infer host from error text', () => { + assert.equal(getIcloudHostHintFromMessage('status 401 from setup.icloud.com.cn/setup/ws/1'), 'icloud.com.cn'); + assert.equal(getIcloudHostHintFromMessage('request failed at https://www.icloud.com/'), 'icloud.com'); + assert.equal(getIcloudHostHintFromMessage('unknown host'), ''); +}); + +test('findIcloudAliasArray finds nested alias collections', () => { + const payload = { + result: { + data: { + items: [ + { hme: 'first@icloud.com', anonymousId: 'a1' }, + { hme: 'second@icloud.com', anonymousId: 'a2' }, + ], + }, + }, + }; + + assert.deepEqual(findIcloudAliasArray(payload), payload.result.data.items); +}); + +test('normalizeIcloudAliasRecord merges used and preserved state', () => { + const alias = normalizeIcloudAliasRecord({ + anonymousId: 'alias-1', + hme: 'Demo@iCloud.com', + label: 'Test', + note: 'Created by test', + state: 'active', + }, { + usedEmails: ['demo@icloud.com'], + preservedEmails: ['demo@icloud.com'], + }); + + assert.deepEqual(alias, { + anonymousId: 'alias-1', + email: 'demo@icloud.com', + label: 'Test', + note: 'Created by test', + state: 'active', + active: true, + used: true, + preserved: true, + createdAt: null, + }); +}); + +test('normalizeIcloudAliasList orders active unused aliases before used aliases', () => { + const aliases = normalizeIcloudAliasList({ + hmeEmails: [ + { hme: 'used@icloud.com', anonymousId: 'u1', active: true }, + { hme: 'fresh@icloud.com', anonymousId: 'f1', active: true }, + { hme: 'inactive@icloud.com', anonymousId: 'i1', active: false }, + ], + }, { + usedEmails: ['used@icloud.com'], + preservedEmails: ['inactive@icloud.com'], + }); + + assert.deepEqual(aliases.map((alias) => alias.email), [ + 'fresh@icloud.com', + 'used@icloud.com', + 'inactive@icloud.com', + ]); + assert.equal(aliases[2].preserved, true); +}); + +test('pickReusableIcloudAlias and findIcloudAliasByEmail select expected aliases', () => { + const aliases = normalizeIcloudAliasList({ + hmeEmails: [ + { hme: 'used@icloud.com', anonymousId: 'u1', active: true }, + { hme: 'fresh@icloud.com', anonymousId: 'f1', active: true }, + ], + }, { + usedEmails: ['used@icloud.com'], + }); + + assert.equal(pickReusableIcloudAlias(aliases)?.email, 'fresh@icloud.com'); + assert.equal(findIcloudAliasByEmail(aliases, 'FRESH@ICLOUD.COM')?.anonymousId, 'f1'); +}); + +test('normalizeBooleanMap and toNormalizedEmailSet normalize keys and truthy entries', () => { + const normalized = normalizeBooleanMap({ + ' Demo@icloud.com ': 1, + 'skip@icloud.com': 0, + }); + + assert.deepEqual(normalized, { + 'demo@icloud.com': true, + 'skip@icloud.com': false, + }); + assert.deepEqual([...toNormalizedEmailSet(normalized)], ['demo@icloud.com']); +}); diff --git a/tests/luckmail-utils.test.js b/tests/luckmail-utils.test.js new file mode 100644 index 0000000..1d9acbc --- /dev/null +++ b/tests/luckmail-utils.test.js @@ -0,0 +1,261 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + DEFAULT_LUCKMAIL_BASE_URL, + DEFAULT_LUCKMAIL_EMAIL_TYPE, + DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME, + buildLuckmailBaselineCursor, + buildLuckmailMailCursor, + extractLuckmailVerificationCode, + filterReusableLuckmailPurchases, + isLuckmailMailNewerThanCursor, + isLuckmailPurchaseForProject, + normalizeLuckmailBaseUrl, + normalizeLuckmailEmailType, + normalizeLuckmailProjectName, + normalizeLuckmailPurchaseListPage, + normalizeLuckmailTags, + normalizeLuckmailUsedPurchases, + normalizeTimestamp, + normalizeLuckmailTokenCode, + normalizeLuckmailTokenMail, + pickReusableLuckmailPurchase, + pickLuckmailVerificationMail, +} = require('../luckmail-utils.js'); + +test('normalizeLuckmailEmailType keeps supported values and falls back to ms_graph', () => { + assert.equal(normalizeLuckmailEmailType('self_built'), 'self_built'); + assert.equal(normalizeLuckmailEmailType('ms_imap'), 'ms_imap'); + assert.equal(normalizeLuckmailEmailType('ms_graph'), 'ms_graph'); + assert.equal(normalizeLuckmailEmailType('google_variant'), 'google_variant'); + assert.equal(normalizeLuckmailEmailType(''), DEFAULT_LUCKMAIL_EMAIL_TYPE); + assert.equal(normalizeLuckmailEmailType('unknown'), DEFAULT_LUCKMAIL_EMAIL_TYPE); +}); + +test('normalizeLuckmailBaseUrl trims invalid input to default base url', () => { + assert.equal(normalizeLuckmailBaseUrl(''), DEFAULT_LUCKMAIL_BASE_URL); + assert.equal(normalizeLuckmailBaseUrl('https://mails.luckyous.com/'), DEFAULT_LUCKMAIL_BASE_URL); + assert.equal(normalizeLuckmailBaseUrl('https://demo.example.com/api/'), 'https://demo.example.com/api'); + assert.equal(normalizeLuckmailBaseUrl('notaurl'), DEFAULT_LUCKMAIL_BASE_URL); +}); + +test('normalizeLuckmailTokenCode and normalizeLuckmailTokenMail extract verification code', () => { + const tokenCode = normalizeLuckmailTokenCode({ + email_address: 'demo@outlook.com', + project: 'openai', + has_new_mail: true, + verification_code: '123456', + mail: { + message_id: 'mail-1', + from: 'noreply@openai.com', + subject: 'Your ChatGPT code is 123456', + received_at: '2026-04-14T10:00:00Z', + }, + }); + + assert.equal(tokenCode.verification_code, '123456'); + assert.equal(tokenCode.mail.message_id, 'mail-1'); + assert.equal(tokenCode.mail.verification_code, '123456'); + + const normalizedMail = normalizeLuckmailTokenMail({ + message_id: 'mail-2', + from: 'noreply@openai.com', + subject: 'OpenAI security message', + body: 'Your verification code is 654321.', + received_at: '2026-04-14T10:01:00Z', + }); + + assert.equal(normalizedMail.verification_code, '654321'); + assert.equal(extractLuckmailVerificationCode('你的验证码为 778899'), '778899'); +}); + +test('normalizeLuckmailProjectName and isLuckmailPurchaseForProject match openai case-insensitively', () => { + assert.equal(normalizeLuckmailProjectName(' OpenAi '), 'openai'); + assert.equal(isLuckmailPurchaseForProject({ + id: 1, + email_address: 'demo@outlook.com', + token: 'tok-1', + project_name: 'OpenAi', + }, 'openai'), true); + assert.equal(isLuckmailPurchaseForProject({ + id: 2, + email_address: 'other@example.com', + token: 'tok-2', + project: 'OtherProject', + }, 'openai'), false); +}); + +test('normalizeLuckmailPurchaseListPage and normalizeLuckmailTags normalize list payloads', () => { + const page = normalizeLuckmailPurchaseListPage({ + list: [{ + id: 3, + email_address: 'demo@outlook.com', + token: 'tok-3', + project_name: 'OpenAi', + }], + total: 4, + page: 2, + page_size: 1, + }); + assert.equal(page.total, 4); + assert.equal(page.page, 2); + assert.equal(page.page_size, 1); + assert.equal(page.list[0].project_code, 'openai'); + + const tags = normalizeLuckmailTags([{ + id: 9, + name: DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME, + limit_type: 0, + }]); + assert.deepEqual(tags[0], { + id: 9, + name: DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME, + remark: '', + limit_type: 0, + purchase_count: 0, + created_at: null, + }); +}); + +test('normalizeLuckmailUsedPurchases keeps positive numeric keys only', () => { + assert.deepEqual(normalizeLuckmailUsedPurchases({ + 1: true, + foo: true, + '-2': true, + 3: false, + }), { + 1: true, + 3: false, + }); +}); + +test('pickReusableLuckmailPurchase only returns reusable openai purchase', () => { + const purchases = [{ + id: 10, + email_address: 'used@outlook.com', + token: 'tok-used', + project_name: 'openai', + }, { + id: 11, + email_address: 'preserved@outlook.com', + token: 'tok-preserved', + project_name: 'OpenAi', + tag_name: DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME, + }, { + id: 12, + email_address: 'disabled@outlook.com', + token: 'tok-disabled', + project_name: 'openai', + user_disabled: 1, + }, { + id: 13, + email_address: 'expired@outlook.com', + token: 'tok-expired', + project_name: 'openai', + warranty_until: '2026-04-14T09:00:00Z', + }, { + id: 14, + email_address: 'other@example.com', + token: 'tok-other', + project_name: 'other', + }, { + id: 15, + email_address: 'ready@outlook.com', + token: 'tok-ready', + project_name: 'OpenAi', + warranty_until: '2026-04-15T09:00:00Z', + }]; + + const reusable = filterReusableLuckmailPurchases(purchases, { + projectCode: 'openai', + usedPurchases: { 10: true }, + preserveTagName: DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME, + now: Date.parse('2026-04-14T10:00:00Z'), + }); + + assert.deepEqual(reusable.map((purchase) => purchase.id), [15]); + assert.equal(pickReusableLuckmailPurchase(purchases, { + projectCode: 'openai', + usedPurchases: { 10: true }, + preserveTagName: DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME, + now: Date.parse('2026-04-14T10:00:00Z'), + }).id, 15); +}); + +test('pickLuckmailVerificationMail respects sender filters, time filters, and excluded codes', () => { + const match = pickLuckmailVerificationMail([ + { + message_id: 'old-mail', + from: 'noreply@openai.com', + subject: 'Your code is 111111', + received_at: '2026-04-14T09:59:00Z', + }, + { + message_id: 'new-mail', + from: 'noreply@openai.com', + subject: 'Your code is 222222', + received_at: '2026-04-14T10:05:00Z', + }, + ], { + senderFilters: ['openai'], + subjectFilters: ['code'], + excludeCodes: ['111111'], + afterTimestamp: Date.parse('2026-04-14T10:00:00Z'), + }); + + assert.equal(match.code, '222222'); + assert.equal(match.mail.message_id, 'new-mail'); +}); + +test('isLuckmailMailNewerThanCursor compares message id and timestamp safely', () => { + const cursor = buildLuckmailMailCursor({ + message_id: 'mail-1', + received_at: '2026-04-14T10:00:00Z', + }); + + assert.equal(isLuckmailMailNewerThanCursor({ + message_id: 'mail-1', + received_at: '2026-04-14T10:00:00Z', + }, cursor), false); + + assert.equal(isLuckmailMailNewerThanCursor({ + message_id: 'mail-2', + received_at: '2026-04-14T10:01:00Z', + }, cursor), true); +}); + +test('normalizeLuckmailMailCursor tolerates null cursor input', () => { + const { normalizeLuckmailMailCursor } = require('../luckmail-utils.js'); + assert.deepEqual(normalizeLuckmailMailCursor(null), { + messageId: '', + receivedAt: '', + }); +}); + +test('normalizeTimestamp treats LuckMail naive datetime strings as UTC', () => { + assert.equal( + normalizeTimestamp('2026-04-14 13:32:05'), + Date.UTC(2026, 3, 14, 13, 32, 5, 0) + ); +}); + +test('buildLuckmailBaselineCursor tracks newest existing mail as baseline', () => { + const cursor = buildLuckmailBaselineCursor([ + { + message_id: 'mail-old', + received_at: '2026-04-14 13:31:15', + subject: '你的 ChatGPT 代码为 111111', + }, + { + message_id: 'mail-new', + received_at: '2026-04-14 13:32:05', + subject: '你的 ChatGPT 代码为 222222', + }, + ]); + + assert.deepEqual(cursor, { + messageId: 'mail-new', + receivedAt: '2026-04-14 13:32:05', + }); +}); diff --git a/tests/microsoft-email.test.js b/tests/microsoft-email.test.js index f46aac2..cf08547 100644 --- a/tests/microsoft-email.test.js +++ b/tests/microsoft-email.test.js @@ -5,9 +5,10 @@ const { extractVerificationCodeFromMessages, fetchMicrosoftMailboxMessages, fetchMicrosoftVerificationCode, + normalizeMailboxId, } = require('../microsoft-email.js'); -test('extractVerificationCodeFromMessages 只命中过滤时间后的 OpenAI 微软邮件', () => { +test('extractVerificationCodeFromMessages 支持显式过滤条件并跳过排除的验证码', () => { const result = extractVerificationCodeFromMessages([ { From: { EmailAddress: { Address: 'noreply@openai.com' } }, @@ -32,6 +33,9 @@ test('extractVerificationCodeFromMessages 只命中过滤时间后的 OpenAI 微 }, ], { filterAfterTimestamp: Date.UTC(2026, 3, 14, 9, 30, 0), + senderFilters: ['openai'], + subjectFilters: ['verification'], + excludeCodes: ['112233'], }); assert.deepEqual(result, { @@ -40,14 +44,46 @@ test('extractVerificationCodeFromMessages 只命中过滤时间后的 OpenAI 微 messageId: 'matched', sender: 'account-security@openai.com', subject: 'OpenAI verification', + mailbox: 'INBOX', + message: { + mailbox: 'INBOX', + from: { + emailAddress: { + address: 'account-security@openai.com', + name: '', + }, + }, + subject: 'OpenAI verification', + receivedDateTime: '2026-04-14T10:05:00.000Z', + bodyPreview: 'Use 334455 to continue', + body: { + content: '', + }, + id: 'matched', + }, }); }); -test('fetchMicrosoftMailboxMessages 使用 refresh token 拉取微软邮箱列表并返回新 token', async () => { +test('normalizeMailboxId 将 Junk 归一为微软邮箱夹 ID', () => { + assert.equal(normalizeMailboxId('INBOX'), 'inbox'); + assert.equal(normalizeMailboxId('junk'), 'junkemail'); + assert.equal(normalizeMailboxId('Junk Email'), 'junkemail'); +}); + +test('fetchMicrosoftMailboxMessages 会回退到可用的 token 策略并保留邮箱夹信息', async () => { const requests = []; const fetchImpl = async (url, options = {}) => { requests.push({ url, options }); if (String(url).includes('/oauth2/v2.0/token')) { + const params = new URLSearchParams(String(options.body || '')); + if (String(url).includes('/common/') && params.get('scope')?.includes('Mail.Read')) { + return { + ok: false, + status: 400, + statusText: 'Bad Request', + text: async () => JSON.stringify({ error_description: 'common delegated failed' }), + }; + } return { ok: true, json: async () => ({ @@ -57,16 +93,17 @@ test('fetchMicrosoftMailboxMessages 使用 refresh token 拉取微软邮箱列 }; } + assert.match(String(url), /graph\.microsoft\.com\/v1\.0\/me\/mailFolders\/junkemail\/messages/); return { ok: true, json: async () => ({ value: [ { - From: { EmailAddress: { Address: 'noreply@openai.com' } }, - Subject: 'OpenAI verification', - BodyPreview: 'Use 445566 to continue', - ReceivedDateTime: '2026-04-14T10:06:00.000Z', - Id: 'mail-1', + from: { emailAddress: { address: 'noreply@openai.com' } }, + subject: 'OpenAI verification', + bodyPreview: 'Use 445566 to continue', + receivedDateTime: '2026-04-14T10:06:00.000Z', + id: 'mail-1', }, ], }), @@ -76,18 +113,25 @@ test('fetchMicrosoftMailboxMessages 使用 refresh token 拉取微软邮箱列 const result = await fetchMicrosoftMailboxMessages({ clientId: 'client-1', refreshToken: 'refresh-token-1', + mailbox: 'Junk', top: 5, fetchImpl, }); - assert.equal(requests.length, 2); + assert.equal(requests.length, 3); assert.equal(result.nextRefreshToken, 'refresh-token-next'); + assert.equal(result.tokenStrategy, 'entra-consumers-delegated'); + assert.equal(result.transport, 'graph'); assert.equal(result.messages.length, 1); assert.equal(result.messages[0].id, 'mail-1'); + assert.equal(result.messages[0].mailbox, 'Junk'); }); -test('fetchMicrosoftVerificationCode 会重试直到命中最新验证码', async () => { - let messageRequestCount = 0; +test('fetchMicrosoftVerificationCode 会按邮箱夹轮询并在 Junk 中命中最新验证码', async () => { + const mailboxRequests = { + inbox: 0, + junkemail: 0, + }; const logs = []; const fetchImpl = async (url) => { if (String(url).includes('/oauth2/v2.0/token')) { @@ -100,8 +144,9 @@ test('fetchMicrosoftVerificationCode 会重试直到命中最新验证码', asyn }; } - messageRequestCount += 1; - if (messageRequestCount === 1) { + const urlString = String(url); + if (urlString.includes('/mailFolders/inbox/messages')) { + mailboxRequests.inbox += 1; return { ok: true, json: async () => ({ @@ -116,11 +161,28 @@ test('fetchMicrosoftVerificationCode 会重试直到命中最新验证码', asyn }; } + assert.match(urlString, /mailFolders\/junkemail\/messages/); + mailboxRequests.junkemail += 1; + if (mailboxRequests.junkemail === 1) { + return { + ok: true, + json: async () => ({ + value: [{ + from: { emailAddress: { address: 'no-reply@example.com' } }, + subject: 'Nothing useful', + bodyPreview: 'Still no code', + receivedDateTime: '2026-04-14T10:05:00.000Z', + id: 'mail-ignore-2', + }], + }), + }; + } + return { ok: true, json: async () => ({ value: [{ - From: { EmailAddress: { Address: 'noreply@openai.com' } }, + from: { emailAddress: { address: 'account-security@openai.com' } }, Subject: 'Your verification code', BodyPreview: '667788', ReceivedDateTime: '2026-04-14T10:10:00.000Z', @@ -135,13 +197,19 @@ test('fetchMicrosoftVerificationCode 会重试直到命中最新验证码', asyn clientId: 'client-2', maxRetries: 2, retryDelayMs: 0, + mailboxes: ['INBOX', 'Junk'], fetchImpl, log: (message) => logs.push(message), filterAfterTimestamp: Date.UTC(2026, 3, 14, 9, 0, 0), + senderFilters: ['openai'], + subjectFilters: ['verification'], }); assert.equal(result.code, '667788'); assert.equal(result.messageId, 'mail-hit'); assert.equal(result.nextRefreshToken, 'refresh-token-next-2'); + assert.equal(result.mailbox, 'Junk'); + assert.equal(mailboxRequests.inbox, 2); + assert.equal(mailboxRequests.junkemail, 2); assert.equal(logs.some((message) => /retrying/i.test(message)), true); }); diff --git a/tests/step9-localhost-cleanup-scope.test.js b/tests/step9-localhost-cleanup-scope.test.js index 08e3d63..97611f4 100644 --- a/tests/step9-localhost-cleanup-scope.test.js +++ b/tests/step9-localhost-cleanup-scope.test.js @@ -52,7 +52,14 @@ function extractFunction(name) { const bundle = [ extractFunction('getTabRegistry'), + extractFunction('normalizeEmailGenerator'), + extractFunction('normalizeMail2925Mode'), + extractFunction('getMail2925Mode'), extractFunction('parseUrlSafely'), + extractFunction('isHotmailProvider'), + extractFunction('isCustomMailProvider'), + extractFunction('isGeneratedAliasProvider'), + extractFunction('shouldUseCustomRegistrationEmail'), extractFunction('isLocalhostOAuthCallbackUrl'), extractFunction('isLocalhostOAuthCallbackTabMatch'), extractFunction('closeLocalhostCallbackTabs'), @@ -62,6 +69,12 @@ const bundle = [ ].join('\n'); const api = new Function(` +const HOTMAIL_PROVIDER = 'hotmail-api'; +const CLOUDFLARE_TEMP_EMAIL_PROVIDER = 'cloudflare-temp-email'; +const CLOUDFLARE_TEMP_EMAIL_GENERATOR = 'cloudflare-temp-email'; +const MAIL_2925_MODE_PROVIDE = 'provide'; +const MAIL_2925_MODE_RECEIVE = 'receive'; +const DEFAULT_MAIL_2925_MODE = MAIL_2925_MODE_PROVIDE; let currentState = { tabRegistry: { 'signup-page': { tabId: 1, ready: true }, @@ -96,12 +109,37 @@ async function setEmailState(email) { currentState = { ...currentState, email }; } +async function setEmailStateSilently(email) { + currentState = { ...currentState, email }; +} + +function isHotmailProvider() { + return false; +} + +function isLuckmailProvider() { + return false; +} + +async function patchHotmailAccount() {} + +async function clearLuckmailRuntimeState() {} + +function shouldUseCustomRegistrationEmail() { + return false; +} + function broadcastDataUpdate() {} async function addLog(message) { logMessages.push(message); } +async function finalizeIcloudAliasAfterSuccessfulFlow() {} +function shouldUseCustomRegistrationEmail() { + return false; +} + ${bundle} return { diff --git a/tests/verification-stop-propagation.test.js b/tests/verification-stop-propagation.test.js index 1ecbea1..dba7532 100644 --- a/tests/verification-stop-propagation.test.js +++ b/tests/verification-stop-propagation.test.js @@ -57,10 +57,12 @@ async function testPollFreshVerificationCodeRethrowsStop() { extractFunction('pollFreshVerificationCode'), ].join('\n'); - const api = new Function(` +const api = new Function(` let stopRequested = false; const STOP_ERROR_MESSAGE = '流程已被用户停止。'; const HOTMAIL_PROVIDER = 'hotmail-api'; +const LUCKMAIL_PROVIDER = 'luckmail-api'; +const CLOUDFLARE_TEMP_EMAIL_PROVIDER = 'cloudflare-temp-email'; const VERIFICATION_POLL_MAX_ROUNDS = 5; const logs = []; let resendCalls = 0; @@ -71,6 +73,9 @@ function getHotmailVerificationPollConfig() { async function pollHotmailVerificationCode() { throw new Error('hotmail path should not run in this test'); } +async function pollLuckmailVerificationCode() { + throw new Error('luckmail path should not run in this test'); +} function getVerificationCodeStateKey(step) { return step === 4 ? 'lastSignupCode' : 'lastLoginCode'; } @@ -119,9 +124,11 @@ async function testResolveVerificationStepRethrowsStopFromFreshRequest() { extractFunction('resolveVerificationStep'), ].join('\n'); - const api = new Function(` +const api = new Function(` const STOP_ERROR_MESSAGE = '流程已被用户停止。'; const HOTMAIL_PROVIDER = 'hotmail-api'; +const LUCKMAIL_PROVIDER = 'luckmail-api'; +const CLOUDFLARE_TEMP_EMAIL_PROVIDER = 'cloudflare-temp-email'; const logs = []; let pollCalls = 0; diff --git a/开发者AI开发与PR提交流程.md b/开发者AI开发与PR提交流程.md new file mode 100644 index 0000000..3621588 --- /dev/null +++ b/开发者AI开发与PR提交流程.md @@ -0,0 +1,372 @@ +# 开发者 AI 开发与 PR 流程 + +请开发者们先让自己的 AI 阅读此文件。 + +AI 在本仓库里进行开发、整理改动、发起 PR、更新 PR、补充说明时,都必须按本文执行,不能跳步,不能猜,不能自创流程。 + +本文面向“开发者自己电脑上的 AI”,不是给仓库维护者批量处理别人 PR 用的,也不是版本发布流程。 + +## 使用前准备 + +在让 AI 操作 GitHub 之前,开发者本机必须先安装 GitHub CLI,并完成登录。 + +最低要求: + +1. 本机已安装 `git` +2. 本机已安装 GitHub CLI,也就是 `gh` +3. 已完成 GitHub 登录 +4. 当前账号对目标仓库至少有读取权限;如果要推分支、改 PR、合并 PR,还需要对应写权限 + +登录示例: + +```powershell +gh auth login +``` + +如果 AI 检查到 `gh` 不可用,或者 `gh auth status` 显示未登录、登录到错误账号、权限不足,则必须先停止并明确告诉开发者,不准假装已经完成 GitHub 操作。 + +## 本文适用场景 + +适用于下面这些任务: + +- 开发新功能 +- 修复 bug +- 清理陈旧逻辑 +- 整理本地提交 +- 发起新的 PR +- 更新已有 PR +- 在自己的 PR 下补充说明 +- 在权限允许时,把自己的 PR 合并到 `dev` + +不适用于: + +- 版本发布 +- 直接把代码合并到 `master` +- 在没看代码上下文的前提下,靠猜测生成 PR 结论 + +## 仓库硬性规则 + +1. 任何结论都不能猜,必须基于真实命令输出、真实 diff、真实代码上下文。 +2. 开发基线分支只能是 `dev`。AI 不得以 `master` 作为日常开发起点,也不得把 PR 目标分支设为 `master`。 +3. 发起 PR 前,必须先同步最新远端提交,确保当前分支已经对齐最新 `origin/dev`。 +4. 如果发现当前 PR 的目标分支是 `master`,AI 必须立刻改为 `dev`,然后重新检查 PR 信息。 +5. 如果当前工作区有无法确认归属的脏改动,AI 必须先停下来告诉开发者,不能偷偷带进本次 PR,也不能擅自删除。 +6. 开发新功能时,不要为了兼容旧逻辑而保留明显无用的陈旧代码;如果确认无其他代码依赖,应一并清理。 +7. 如果旧逻辑本身设计差、实现混乱或者存在 bug,AI 需要继续检查相关调用点;在不影响其他功能的前提下,可以顺手一起修正。 +8. 如果改动了 SQL 文件,必须按仓库规则同步本地 MySQL:账号 `root`,密码 `123456`,数据库 `xzs`。 +9. 默认不编译、不跑测试。开发完成后提醒开发者自行测试。 +10. 如果是前端改动且没有新增依赖,只提醒开发者自己测试即可;如果新增了依赖并完成安装,可以自行验证能否启动,验证后要关闭启动占用的端口,并提醒开发者重新启动。 +11. PR 标题、PR 正文、PR 评论都用自然中文直接表达,不要写“自动回复”“AI 分析结果如下”这种固定机器人腔。 +12. 没有开发者明确授权时,AI 不得擅自合并 PR、关闭 PR、删除远端分支。 + +## 开发者需要提供给 AI 的信息 + +至少提供下面这些内容: + +- 仓库本地路径 +- 本次要做的功能或问题描述 +- 是新任务,还是继续一个已有分支/已有 PR +- 如果已经有 PR,要提供 PR 编号 +- 是否允许 AI 在自己的功能分支上执行 `rebase` +- 是否允许 AI 在最后直接发起 PR +- 如果 PR 已经审完,是否允许 AI 直接合并到 `dev` + +如果这些关键信息缺失,AI 不能靠猜来补。 + +## 标准执行顺序 + +### 阶段 1:环境确认与仓库现状检查 + +AI 开始干活前,先执行下面这些动作: + +```powershell +gh --version +gh auth status +git status --short --branch +git remote -v +git branch --show-current +git fetch origin +``` + +要求: + +1. 必须先确认 `gh` 可用且已登录。 +2. 必须确认当前仓库远端是正确仓库。 +3. 必须确认当前工作区是否有未提交改动。 +4. 不能在没看当前分支状态的情况下直接开始写代码或直接发 PR。 + +### 阶段 2:先对齐最新 `dev` + +#### 场景 A:这是一个新任务 + +如果是新任务,还没开始写代码,则必须先同步最新 `dev`: + +```powershell +git switch dev +git pull --ff-only origin dev +git switch -c <feature-branch> +``` + +规则: + +1. 新任务必须从最新 `dev` 拉出功能分支。 +2. 不允许直接在 `dev` 上开发。 +3. 不允许从 `master` 拉开发分支。 + +#### 场景 B:这是一个已有分支上的继续开发 + +如果开发已经在某个功能分支上进行,则不能为了同步 `dev` 直接丢本地改动。 + +这时至少要先执行: + +```powershell +git fetch origin +git rev-list --left-right --count origin/dev...HEAD +git log --oneline HEAD..origin/dev +``` + +要求: + +1. 必须真实判断当前分支是否已经落后于 `origin/dev`。 +2. 如果当前分支落后,先继续开发可以,但在发起 PR 前必须补齐最新 `dev`。 +3. 如果当前分支已经出现复杂冲突风险,AI 需要提前告诉开发者,不要拖到最后一刻再爆。 + +### 阶段 3:开发与本地整理 + +AI 开发时,必须遵守下面这些要求: + +1. 先读相关代码上下文,再改代码。 +2. 不能只改表面调用点,必须检查相关联的状态流、配置项、消息流、页面流程、回调流程。 +3. 如果发现本次功能附近本来就有坏逻辑,而且修复不会影响其他已使用代码,可以顺手一并修正。 +4. 如果为了完成新功能必须删除旧逻辑,就删除,不要为了“看起来兼容”堆陈旧代码。 +5. 改完后必须自己检查: + - `git diff --stat` + - `git diff` + - 相关文件中是否残留冲突标记 + - 是否混入无关改动 +6. 如果改了 SQL,别忘了同步本地 MySQL `xzs`。 + +### 阶段 4:发起 PR 前必须再次同步最新 `dev` + +这是硬规则。 + +无论这个分支什么时候开始开发,只要准备发起 PR,就必须再次拉取远端最新提交,并确认当前分支已经吸收了最新 `origin/dev`。 + +先执行: + +```powershell +git fetch origin +git log --oneline HEAD..origin/dev +``` + +#### 如果 `origin/dev` 没有新提交 + +可以继续进入下一阶段。 + +#### 如果 `origin/dev` 有新提交 + +优先处理方式: + +```powershell +git rebase origin/dev +``` + +如果开发者明确禁止改写当前分支历史,或者这个分支已经有多人共同使用,再改用: + +```powershell +git merge origin/dev +``` + +处理规则: + +1. 如果执行了 `rebase`,必须重新检查 diff,确认没有把逻辑改坏。 +2. 如果执行了 `rebase` 且当前分支之前已经推到远端,后续推送时只能使用: + +```powershell +git push --force-with-lease origin <feature-branch> +``` + +3. 不允许使用 `git push --force`。 +4. 不能只把冲突标记删掉就算完,必须继续检查冲突两边的真实逻辑是否仍然一致。 + +### 阶段 5:提交与推送 + +在发起 PR 前,AI 必须先把本次改动整理干净。 + +至少要执行下面这些检查: + +```powershell +git status --short +git diff --stat origin/dev...HEAD +git diff origin/dev...HEAD +``` + +要求: + +1. PR 中只能包含与本次任务相关的改动。 +2. 不要把临时调试代码、无关格式化、无关文件重命名、构建产物一起带进 PR。 +3. 提交信息必须描述真实功能结果,不要写: + - `update` + - `fix bug` + - `merge branch` + - `修改一下` +4. 推送前要确认当前分支不是 `dev`,也不是 `master`。 + +推送示例: + +```powershell +git push -u origin <feature-branch> +``` + +### 阶段 6:创建或更新 PR + +PR 只能指向 `dev`。 + +创建 PR 时使用: + +```powershell +gh pr create --base dev --head <feature-branch> --title "<PR标题>" --body-file <PR正文文件> +``` + +如果 PR 已经存在,则执行: + +```powershell +gh pr view <PR_NUMBER> --json number,title,baseRefName,headRefName,state,isDraft,url +``` + +如果发现已有 PR 的目标分支不是 `dev`,必须改正: + +```powershell +gh pr edit <PR_NUMBER> --base dev +``` + +改完后,再重新读取一次 PR 信息,确认: + +- `baseRefName = dev` +- PR 仍是 open +- head 分支正确 + +#### PR 标题要求 + +1. 直接描述功能结果或修复结果。 +2. 不要把标题写成 Git 动作描述。 +3. 不要写空洞标题。 + +#### PR 正文要求 + +PR 正文直接写真实信息,建议结构如下: + +```markdown +## 本次改动 +- ... + +## 风险与影响 +- ... + +## 测试情况 +- 未运行测试,请开发者自行验证 +``` + +说明: + +1. 正文必须基于真实改动来写。 +2. 不要写固定“自动回复”抬头。 +3. 不要写和代码不相符的夸大表述。 + +### 阶段 7:PR 后续补充说明 + +如果 AI 需要在 PR 里补充评论、解释冲突、说明待确认点,要求如下: + +1. 直接写清楚问题、原因、影响、建议。 +2. 语气自然、简洁、可读。 +3. 不要使用固定机器人模板。 +4. 不要为了“像 AI”而加免责声明废话。 +5. 评论内容必须和真实代码、真实 diff、真实冲突一致。 + +### 阶段 8:只有在明确授权时,才允许合并 PR + +如果开发者明确要求 AI 继续合并自己的 PR,则必须先再次确认: + +```powershell +gh pr view <PR_NUMBER> --json number,title,baseRefName,headRefName,state,isDraft,mergeable,mergeStateStatus,url +git fetch origin +git log --oneline HEAD..origin/dev +``` + +合并前必须满足: + +1. PR 目标分支是 `dev` +2. PR 不是 draft +3. PR 仍然是 open +4. 当前分支已经吸收了最新 `origin/dev` +5. 没有尚未处理的明确问题 +6. 开发者已经明确授权合并 + +如果满足以上条件,才可以执行 GitHub 合并,例如: + +```powershell +gh pr merge <PR_NUMBER> --merge --delete-branch +``` + +限制: + +1. AI 只能把自己的 PR 合并到 `dev`。 +2. AI 不得把任何开发分支直接合并到 `master`。 +3. 如果发现 PR 目标分支是 `master`,先改成 `dev`,再重新核对是否允许继续。 + +## 开发清单 + +开发时按下面清单自检,避免漏项: + +### 第 1 阶段:开始前 + +- `gh` 已安装且已登录 +- 当前仓库正确 +- 当前工作区状态已确认 +- 已明确本次任务是否是新任务还是续做 + +### 第 2 阶段:开发前基线 + +- 新任务已从最新 `dev` 拉分支 +- 续做任务已确认自己相对 `origin/dev` 的落后情况 +- 没有误在 `master` 或 `dev` 上直接开发 + +### 第 3 阶段:开发中 + +- 代码上下文已阅读 +- 相关联逻辑已检查 +- 无用旧代码已清理 +- SQL 改动已同步本地数据库 + +### 第 4 阶段:发 PR 前 + +- 已再次获取最新 `origin/dev` +- 已完成 `rebase` 或 `merge` +- diff 只包含本次任务改动 +- 提交信息清晰 +- 当前分支不是 `dev` / `master` + +### 第 5 阶段:PR 与收尾 + +- PR 目标分支确认是 `dev` +- PR 标题和正文与真实改动一致 +- 如有评论,内容为自然中文,不用固定机器人模板 +- 如果未跑测试,已明确提醒开发者测试 + +## 最终反馈给开发者时必须说明 + +AI 完成后,至少要向开发者明确反馈下面这些信息: + +1. 实际执行了哪些命令和动作 +2. 当前分支是否已经同步最新 `origin/dev` +3. 是否创建了新 PR,或者更新了已有 PR +4. PR 编号和链接是什么 +5. PR 目标分支是否确认是 `dev` +6. 是否执行了 `rebase` 或 `merge` +7. 是否改了 SQL 并同步了本地数据库 +8. 是否运行过测试;如果没跑,要明确提醒开发者自行测试 +9. 是否已经合并;如果已合并,要明确说明是合并到 `dev` + +## 一句话执行要求 + +AI 在本仓库里做开发与提 PR 时,必须按“先确认环境与当前工作区,再对齐最新 `dev`,再开发与整理改动,再次同步最新 `dev`,最后只向 `dev` 发起或更新 PR;只有在开发者明确授权时,才允许把自己的 PR 合并到 `dev`”的顺序执行,不能跳步,不能猜,不能偷懒。