Import sanitized project structure and GitHub docs

This commit is contained in:
Rixuan Shao
2026-05-17 18:28:25 +08:00
parent d7e6bb1628
commit de4f3aeb74
87 changed files with 16696 additions and 1 deletions
@@ -0,0 +1,446 @@
# Multi-Page Automation
一个用于批量跑通 ChatGPT OAuth 注册/登录流程的 Chrome 扩展。
当前版本基于侧边栏控制,支持单步执行、整套自动执行、停止当前流程、保存常用配置,以及通过 DuckDuckGo / Firefox Relay / Cloudflare Temp Email / QQ / 163 / Inbucket mailbox 协助完成注册邮箱与验证码处理。
## 当前能力
- 从 VPS 面板自动获取 OpenAI OAuth 授权链接
- 自动打开 OpenAI 注册页并点击 `Sign up / Register`
- 自动填写邮箱与密码
- 支持自定义密码;留空时自动生成强密码
- 自动显示当前使用中的密码,便于后续保存
- 自动获取注册验证码与登录验证码
- 支持 `QQ Mail``163 Mail``Inbucket mailbox`
- 支持从 DuckDuckGo Email Protection 自动生成新的 `@duck.com` 地址
- 支持从 Firefox Relay 自动创建新的 `@mozmail.com` mask
- 支持从侧边栏配置的 Cloudflare Temp Email admin 页面自动创建新的临时邮箱
- Step 5 同时兼容两种页面:
- 页面要求填写 `birthday`
- 页面要求填写 `age`
- 支持 `Auto` 多轮运行
- 支持中途 `Stop`
- Step 8 会自动寻找 OAuth 同意页的“继续”按钮,并通过 Chrome debugger 输入事件发起点击,然后监听本地回调地址
## 环境要求
- Chrome 浏览器
- 打开扩展开发者模式
- 你自己的 VPS 管理面板,且页面结构与当前脚本适配
- 至少准备一种验证码接收方式:
- DuckDuckGo `@duck.com` + QQ / 163 / Inbucket 转发
- 可创建并收信的 Cloudflare Temp Email admin 页面,留空时默认使用 `https://mail.cloudflare.com/admin`
- 手动填写一个可收信邮箱
- 如果使用 `QQ` / `163` / `Inbucket`,对应页面需要提前能正常打开
## 安装
1. 打开 `chrome://extensions/`
2. 开启“开发者模式”
3. 点击“加载已解压的扩展程序”
4. 选择本项目目录
5. 打开扩展侧边栏
## 侧边栏配置说明
### `VPS`
你的管理面板 OAuth 页面地址,例如:
```txt
http(s)://<your-host>/management.html#/oauth
```
Step 1 依赖这个地址。Step 9 已改为邮箱资源清理,不再执行 VPS Verify。
### `Mail`
支持三种验证码来源:
- `163 Mail`
- `QQ Mail`
- `Inbucket`
说明:
- `QQ``163` 用于直接轮询网页邮箱
- `Inbucket` 通过你在侧边栏里配置的 host 访问 `mailbox` 页面:`https://<your-inbucket-host>/m/<mailbox>/`
### `Mailbox`
仅当 `Mail = Inbucket` 时显示。
填写 Inbucket mailbox 名称,例如:
```txt
tmp-mailbox
```
脚本会自动打开:
```txt
https://<your-inbucket-host>/m/<mailbox>/
```
并且只检索未读邮件:
- 只匹配 `.message-list-entry.unseen`
- 第 2 次轮询开始会自动点击 mailbox 页面上的刷新按钮
- 识别到验证码后会尝试删除当前邮件,减少重复命中
### `Inbucket`
仅当 `Mail = Inbucket` 时显示。
这里填写 Inbucket host,支持两种格式:
- `your-inbucket-host`
- `https://your-inbucket-host`
脚本会自动规范化成 origin 后再拼接 mailbox URL。
### `Email`
Step 3 使用的注册邮箱。
来源有三种:
- 手动粘贴
- 选择 `duckduckgo` 后点击 `Auto`,从 DuckDuckGo Email Protection 自动获取一个新的 `@duck.com`
- 选择 `cloudflare_temp_email` 后点击 `Auto`,从 `Cloudflare` 输入框对应的 admin 页面自动创建一个新的临时邮箱;留空时默认使用 `https://mail.cloudflare.com/admin`
- 选择 `relay_firefox` 后点击 `Auto`,从 Firefox Relay 自动创建一个新的 `@mozmail.com` mask
注意:
- `Auto` 按钮会根据当前 `Email Source` 选择不同提供方
- `relay_firefox` 模式下,Step 3 会优先自动创建新的 Relay mask
- 如果你使用 Inbucket,它只是验证码收件箱,不会自动生成 Inbucket 地址
### `Email Source`
用于控制 Step 3 的注册邮箱来源:
- `duckduckgo`
- `cloudflare_temp_email`
- `relay_firefox`
它和 `Mail` 配置是独立的:
- `Email Source` 决定注册时用哪个邮箱地址
- `Mail` 决定 Step 4 / Step 7 去哪里收验证码
例外:
-`Email Source = cloudflare_temp_email` 时,Step 4 / Step 7 会直接回到 `Cloudflare` 输入框对应的 admin 页面收验证码;留空时默认使用 `https://mail.cloudflare.com/admin`,不使用 `Mail` 配置
### `Cloudflare`
仅当 `Email Source = cloudflare_temp_email` 时显示。
这里填写 Cloudflare Temp Email admin 页面地址,支持两种格式:
- `mail.cloudflare.com/admin`
- `https://mail.cloudflare.com/admin`
行为说明:
- 留空时默认使用 `https://mail.cloudflare.com/admin`
- 输入缺少协议时,会自动补成 `https://`
- Step 3 / Step 4 / Step 7 都会复用这里的地址
### `Password`
- 留空:自动生成强密码
- 手动输入:使用你自定义的密码
- 可通过 `Show / Hide` 按钮切换显示
扩展会把本轮实际使用的密码同步回侧边栏,便于查看和复制。
### `Auto`
整套流程自动跑。
支持多轮运行,运行次数由右上角数字框决定。
## 工作流
### 单步模式
侧边栏共有 9 个步骤按钮,可逐步执行:
1. `Get OAuth Link`
2. `Open Signup`
3. `Fill Email / Password`
4. `Get Signup Code`
5. `Fill Name / Birthday`
6. `Login via OAuth`
7. `Get Login Code`
8. `Manual OAuth Confirm`
9. `Cleanup Email`
### Auto 模式
点击右上角 `Auto` 后,后台会按顺序跑完整流程。
当前 Auto 逻辑是:
1. Step 1 获取 VPS OAuth 链接
2. Step 2 打开 OpenAI 注册页
3.`Email Source` 尝试自动准备注册邮箱
4. 如果邮箱自动获取 / 创建失败,暂停并等待你在侧边栏修复后点击 `Continue`
5. 继续执行 Step 3 ~ Step 9
也就是说:
- 如果当前邮箱来源可自动完成,整套流程更接近全自动
- 如果不能自动获取或创建,Auto 会在邮箱阶段暂停
## 详细步骤说明
### Step 1: Get OAuth Link
通过 `content/vps-panel.js`
- 打开 VPS OAuth 面板
- 等待 `Codex OAuth` 卡片出现
- 点击“登录”
- 读取页面里的授权链接
结果会保存到侧边栏的 `OAuth` 字段。
### Step 2: Open Signup
通过 `content/signup-page.js`
- 打开授权链接
- 查找 `Sign up / Register / 创建账户` 按钮
- 自动点击进入注册流程
### Step 3: Fill Email / Password
- 自动填写邮箱
- 如页面先要求邮箱,再进入密码页,会自动切页继续填写
- 使用自定义密码或自动生成密码
- 提交注册表单
`Email Source = relay_firefox` 时,后台会在填写前先打开 `https://relay.firefox.com/accounts/profile/` 创建一个新的 mask 邮箱,并自动补一个 `tN` 账户名。
`Email Source = cloudflare_temp_email` 时,后台会在填写前先打开侧边栏 `Cloudflare` 输入框对应的 admin 页面;如果留空,则默认使用 `https://mail.cloudflare.com/admin`。随后进入 `账号 -> 创建账号`,默认关闭前缀开关,再创建一个新的临时邮箱。
实际使用的密码会写入会话状态,并同步到侧边栏显示。
### Step 4: Get Signup Code
默认根据 `Mail` 配置,轮询邮箱并提取 6 位验证码。
支持:
- `content/qq-mail.js`
- `content/mail-163.js`
- `content/inbucket-mail.js`
邮件匹配规则以以下关键词为主:
- 发件人:`openai``noreply``verify``auth``duckduckgo``forward`
- 标题:`verify``verification``code``验证``confirm`
`Email Source = cloudflare_temp_email` 时:
- 不使用 `Mail`
- 直接打开侧边栏 `Cloudflare` 输入框对应的 admin 页面;如果留空,则默认使用 `https://mail.cloudflare.com/admin`
- 通过 `刷新` 轮询当前注册邮箱的验证码邮件
### Step 5: Fill Name / Birthday
随机生成人名与生日。
当前脚本支持两种页面结构:
- 页面要求 `birthday`
- 页面要求 `age`
如果页面是生日模式,会填写年月日;如果页面上存在 `input[name='age']`,则直接填写年龄。
### Step 6: Login via OAuth
重新打开 OAuth 链接,使用刚注册的账号登录。
支持:
- 邮箱 + 密码登录
- 提交后进入验证码验证流程
### Step 7: Get Login Code
与 Step 4 类似,但会使用稍微不同的关键词组合去找登录验证码邮件。
`Email Source = cloudflare_temp_email` 时,仍然走 admin 页轮询,并且只接受时间上能证明晚于 Step 4 的邮件;如果无法证明是更新邮件,会直接失败而不是复用旧验证码。
### Step 8: Manual OAuth Confirm
虽然按钮名称还是 `Manual OAuth Confirm`,但当前代码已经做了自动尝试:
- 在授权页定位“继续”按钮
- 等待按钮可点击
- 获取按钮坐标
- 通过 Chrome `debugger` 的输入事件点击该按钮
- 同时监听 `chrome.webNavigation.onBeforeNavigate`
- 一旦捕获本地回调地址,就把结果保存到 `Callback`
注意:
- 这一步仍然是最容易因页面变化而失效的一步
- 如果 120 秒内没有捕获到 localhost 回调,会报错超时
- README 中的按钮名称沿用了旧文案,但代码行为是“自动尝试点击”
### Step 9: Cleanup Email
Step 9 现在用于清理本轮邮箱资源:
- `duckduckgo`:跳过,不做清理
- `cloudflare_temp_email`:跳过,不做清理
- `relay_firefox`:回到 Firefox Relay 页面,删除本轮刚创建的那个 mask 邮箱
## Duck 邮箱自动获取
通过 `content/duck-mail.js`
- 打开 DuckDuckGo Email Protection Autofill 设置页
- 查找当前私有地址
- 如需要,点击 `Generate Private Duck Address`
- 读取新的 `@duck.com` 地址
这个功能会被:
- 侧边栏 `Email` 旁边的 `Auto` 按钮使用
- `Email Source = duckduckgo``Auto Run` 流程优先尝试使用
## Firefox Relay 自动创建 / 删除
通过 `content/relay-firefox.js`
- 打开 Firefox Relay profile 页面
- 点击 `Generate new mask`
- 读取新的 `@mozmail.com` 地址
- 自动设置下一个可用的 `tN` 标签
- 在 Step 9 删除本轮创建的 mask
## Cloudflare Temp Email 自动创建 / 收码
通过 `content/cloudflare-temp-email.js`
- 打开侧边栏 `Cloudflare` 输入框对应的 admin 页面;如果留空,则默认使用 `https://mail.cloudflare.com/admin`
-`账号 -> 创建账号` 默认关闭前缀,再创建新邮箱
- 从创建成功弹窗里读取邮箱地址和 address id
-`邮件` 页通过 `查询` + `刷新` 轮询目标邮箱
- 提取 Step 4 / Step 7 需要的 6 位验证码
## 停止机制
扩展内置了停止当前流程的能力:
- 侧边栏点击 `Stop`
- Background 会广播 `STOP_FLOW`
- 各 content script 会在等待、轮询、sleep、元素查找中尽量中断
适合以下场景:
- 卡在某一步
- 邮件迟迟不来
- 页面结构变化导致等待超时
## 状态与数据
主要使用 `chrome.storage.session` 保存运行时状态:
- 当前步骤
- 每一步状态
- OAuth 链接
- 当前邮箱
- 当前密码
- localhost 回调地址
- 账号记录
- tab 注册信息
- 自定义设置
特点:
- 浏览器会话级存储
- 扩展运行期间可在多个步骤之间共享
- 代码里已启用 `storage.session` 对 content script 的访问
## 项目结构
```txt
background.js 后台主控,编排 1~9 步、Tab 复用、状态管理
manifest.json 扩展清单
data/names.js 随机姓名、生日数据
content/utils.js 通用工具:等待元素、点击、日志、停止控制
content/vps-panel.js VPS 面板步骤:Step 1
content/signup-page.js OpenAI 注册/登录页步骤:Step 2 / 3 / 5 / 6 / 8
content/duck-mail.js Duck 邮箱自动获取
shared/cloudflare-temp-email.js Cloudflare Temp Email 纯逻辑辅助
content/cloudflare-temp-email.js Cloudflare Temp Email 创建 / 轮询
content/relay-firefox.js Firefox Relay mask 创建 / 删除
content/qq-mail.js QQ 邮箱验证码轮询
content/mail-163.js 163 邮箱验证码轮询
content/inbucket-mail.js Inbucket mailbox 验证码轮询
sidepanel/ 侧边栏 UI
```
## 常见使用建议
### 1. 先单步验证,再开 Auto
推荐先手动跑通一次:
1. Step 1
2. Step 2
3. Step 3
4. Step 4
确认邮箱和验证码链路稳定后,再使用 `Auto`
### 2. Inbucket 建议使用专用 mailbox
当前 Inbucket 逻辑只看未读邮件,但还是建议:
- 给脚本准备一个相对独立的 mailbox
- 避免收件箱里混入过多无关邮件
### 3. 邮箱自动获取失败时直接手动修复
如果 Duck 或 Relay 页面打不开、未登录或按钮变化:
- 直接在 `Email` 输入框中粘贴邮箱
- 再继续执行 Step 3 或 Auto Continue
### 4. Step 8 失败时重点检查
- OAuth 同意页 DOM 是否变化
- “继续”按钮是否变成了别的文案
- localhost 回调是否真的触发
- 浏览器是否允许 debugger 附加
## 已知限制
- Step 8 对页面结构较敏感
- Duck / Relay 自动获取依赖各自页面真实 DOM
- VPS 面板 DOM 也需要和当前脚本选择器匹配
- `Auto` 按钮名称和 Step 8 的旧文案还未完全统一,但代码行为以实际实现为准
## 调试建议
- 打开扩展侧边栏看日志
- 查看 Service Worker 控制台
- 查看目标页面的 content script 控制台日志
- 当某一步频繁失败时,优先检查当前页面选择器是否仍然匹配
## 安全说明
- 所有状态仅保存在浏览器会话中
- 没有硬编码你的 VPS 地址、密码或账户
- 自定义密码只存在当前会话存储中
- 邮箱和密码会被记录到本轮 `accounts` 中,便于追踪本次运行结果
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,531 @@
// content/cloudflare-temp-email.js — Content script for Cloudflare Temp Email admin page
const CLOUDFLARE_TEMP_EMAIL_PREFIX = '[MultiPage:cloudflare-temp-email]';
const isTopFrame = window === window.top;
const {
combineDistinctTextParts = (parts = []) => parts
.map((part) => String(part || '').replace(/\s+/g, ' ').trim())
.filter(Boolean)
.filter((part, index, values) => values.indexOf(part) === index)
.join(' '),
extractVerificationCode = () => null,
generateReadableLocalPart = () => `mp${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`.slice(0, 18),
normalizeDomainSuffix = (value) => String(value || '').trim().replace(/^@+/, '').toLowerCase(),
parseCloudflareMailboxCredential = () => null,
pickRandomSuffix = (options = []) => normalizeDomainSuffix(options[0] || ''),
selectVerificationMessage = () => null,
} = globalThis.MultiPageCloudflareTempEmail || {};
console.log(CLOUDFLARE_TEMP_EMAIL_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
if (!isTopFrame) {
console.log(CLOUDFLARE_TEMP_EMAIL_PREFIX, 'Skipping child frame');
} else {
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type !== 'CREATE_CLOUDFLARE_TEMP_EMAIL' && message.type !== 'POLL_EMAIL') {
return;
}
resetStopState();
const handler = message.type === 'CREATE_CLOUDFLARE_TEMP_EMAIL'
? createCloudflareTempEmail
: pollCloudflareTempEmail;
handler(message.step, message.payload || {}).then((result) => {
sendResponse(result);
}).catch((err) => {
if (isStopError(err)) {
if (message.step) {
log(`Step ${message.step}: Stopped by user.`, 'warn');
} else {
log('Cloudflare Temp Email: Stopped by user.', 'warn');
}
sendResponse({ stopped: true, error: err.message });
return;
}
if (message.step) {
reportError(message.step, err.message);
}
sendResponse({ error: err.message });
});
return true;
});
function getElementText(el) {
return combineDistinctTextParts([
el?.innerText,
el?.textContent,
el?.getAttribute?.('aria-label'),
el?.getAttribute?.('title'),
el?.value,
]);
}
function normalizeText(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function normalizeEmail(value) {
return normalizeText(value).toLowerCase();
}
function isVisible(el) {
if (!el) return false;
if (el.hidden) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return false;
}
return Boolean(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
}
function findVisibleButton(pattern) {
return Array.from(document.querySelectorAll('button'))
.filter(isVisible)
.find((button) => pattern.test(normalizeText(getElementText(button))));
}
function findVisibleTab(pattern, occurrence = 'first') {
const tabs = Array.from(document.querySelectorAll('.n-tabs-tab'))
.filter(isVisible)
.filter((tab) => pattern.test(normalizeText(getElementText(tab))));
return occurrence === 'last' ? tabs[tabs.length - 1] || null : tabs[0] || null;
}
async function waitForCondition(predicate, timeout, message) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeout) {
throwIfStopped();
const value = predicate();
if (value) {
return value;
}
await sleep(150);
}
throw new Error(message);
}
async function clickTab(pattern, occurrence = 'first', timeout = 10000) {
await waitForCondition(
() => findVisibleTab(pattern, occurrence),
timeout,
`Timed out waiting for tab ${pattern}`
);
const tab = findVisibleTab(pattern, occurrence);
if (!tab) {
throw new Error(`Could not find tab ${pattern}`);
}
await humanPause(120, 260);
simulateClick(tab);
await sleep(400);
return tab;
}
function getCreateAddressInput() {
return Array.from(document.querySelectorAll('input[placeholder="请输入"]')).find(isVisible) || null;
}
function getPrefixSwitch() {
return Array.from(document.querySelectorAll('[role="switch"], .n-switch')).find(isVisible) || null;
}
function getCreateInputGroup() {
return getCreateAddressInput()?.closest('.n-input-group') || null;
}
function getCreateDomainSelect() {
const group = getCreateInputGroup();
return Array.from(group?.querySelectorAll('.n-base-selection') || []).find(isVisible)
|| Array.from(group?.querySelectorAll('.n-select') || []).find(isVisible)
|| null;
}
function getCurrentDomain() {
const groupText = normalizeText(getCreateInputGroup()?.textContent || '');
const match = groupText.match(/@([a-z0-9.-]+\.[a-z]{2,})/i);
return normalizeDomainSuffix(match ? match[1] : '');
}
function getVisibleDomainOptionEntries() {
const seen = new Set();
const entries = [];
for (const option of Array.from(document.querySelectorAll('.n-base-select-option, [role="option"]'))) {
if (!isVisible(option)) {
continue;
}
const value = normalizeDomainSuffix(getElementText(option));
if (!value || seen.has(value)) {
continue;
}
seen.add(value);
entries.push({ option, value });
}
return entries;
}
async function ensureCreateAccountPage() {
await clickTab(/^账号$/, 'first');
await clickTab(/^创建账号$/);
await waitForCondition(
() => getCreateAddressInput(),
10000,
'Cloudflare Temp Email create form did not load.'
);
}
async function ensureMailPage() {
await clickTab(/^邮件$/, 'first');
await clickTab(/^邮件$/, 'last');
await waitForCondition(
() => document.querySelector('input[placeholder="留空查询所有地址"]'),
10000,
'Cloudflare Temp Email mail page did not load.'
);
}
async function ensurePrefixDisabled() {
const prefixSwitch = getPrefixSwitch();
if (!prefixSwitch) {
log('Cloudflare Temp Email: Prefix switch not present, assuming prefix is already disabled', 'info');
return;
}
if (prefixSwitch.getAttribute('aria-checked') === 'true') {
await humanPause(120, 280);
simulateClick(prefixSwitch);
await waitForCondition(
() => prefixSwitch.getAttribute('aria-checked') === 'false',
5000,
'Cloudflare Temp Email prefix switch did not turn off.'
);
log('Cloudflare Temp Email: Prefix disabled', 'ok');
}
}
async function selectRandomDomainSuffix() {
const domainSelect = await waitForCondition(
() => getCreateDomainSelect(),
5000,
'Could not find the Cloudflare Temp Email suffix selector.'
);
await humanPause(120, 260);
simulateClick(domainSelect);
await sleep(300);
const optionEntries = await waitForCondition(
() => {
const entries = getVisibleDomainOptionEntries();
return entries.length > 0 ? entries : null;
},
5000,
'Could not find any available Cloudflare Temp Email suffix options.'
);
const suffix = pickRandomSuffix(optionEntries.map((entry) => entry.value));
const selectedEntry = optionEntries.find((entry) => entry.value === suffix);
if (!suffix || !selectedEntry?.option) {
throw new Error('Could not choose a Cloudflare Temp Email suffix from the available options.');
}
await humanPause(120, 260);
simulateClick(selectedEntry.option);
await waitForCondition(
() => getCurrentDomain() === suffix,
5000,
`Cloudflare Temp Email suffix did not switch to ${suffix}.`
);
log(`Cloudflare Temp Email: Selected suffix ${suffix}`, 'info');
return suffix;
}
function generateLocalPart() {
return generateReadableLocalPart();
}
function findCredentialDialog() {
return Array.from(
document.querySelectorAll('[role="dialog"], .n-dialog, .n-modal, .n-base-modal, .n-card')
).find((el) => isVisible(el) && /邮箱地址凭证/.test(getElementText(el)));
}
function extractCredentialToken(root) {
if (!root) return '';
const candidates = [
getElementText(root),
...Array.from(root.querySelectorAll('textarea, input, pre, code')).map((el) => el.value || el.textContent || ''),
];
for (const candidate of candidates) {
const match = String(candidate || '').match(/[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/);
if (match) {
return match[0];
}
}
return '';
}
async function waitForCredentialDetails(timeout = 15000) {
return waitForCondition(() => {
const dialog = findCredentialDialog();
const token = extractCredentialToken(dialog);
const credential = parseCloudflareMailboxCredential(token);
if (!credential?.email) {
return null;
}
return credential;
}, timeout, 'Timed out waiting for Cloudflare Temp Email credential dialog.');
}
async function dismissCredentialDialog() {
const dialog = findCredentialDialog();
if (!dialog) return;
const closeButton = Array.from(dialog.querySelectorAll('button, .n-base-close'))
.find((el) => isVisible(el) && (/关闭|确定|取消/.test(getElementText(el)) || el.classList?.contains('n-base-close')));
if (closeButton) {
simulateClick(closeButton);
await sleep(300);
return;
}
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
await sleep(300);
}
async function createCloudflareTempEmail(step, payload = {}) {
const { generateNew = true } = payload;
await ensureCreateAccountPage();
await ensurePrefixDisabled();
await selectRandomDomainSuffix();
const input = getCreateAddressInput();
if (!input) {
throw new Error('Could not find the Cloudflare Temp Email address input.');
}
const createButton = findVisibleButton(/^创建新邮箱$/);
if (!createButton) {
throw new Error('Could not find the "创建新邮箱" button.');
}
for (let attempt = 1; attempt <= 3; attempt++) {
const localPart = generateLocalPart();
fillInput(input, localPart);
await humanPause(120, 260);
simulateClick(createButton);
log(`Cloudflare Temp Email: Creating mailbox attempt ${attempt}`, 'info');
try {
const credential = await waitForCredentialDetails(10000);
await dismissCredentialDialog();
return {
...credential,
domain: credential.domain || getCurrentDomain(),
generated: Boolean(generateNew),
};
} catch (err) {
if (attempt === 3) {
throw err;
}
log(`Cloudflare Temp Email: Mailbox attempt ${attempt} did not complete, retrying`, 'warn');
await dismissCredentialDialog().catch(() => {});
await sleep(500);
}
}
throw new Error('Cloudflare Temp Email mailbox creation did not succeed.');
}
function getMessageRows() {
return Array.from(document.querySelectorAll('.n-thing')).filter(isVisible);
}
function parseMessageRow(row) {
const rowText = row?.innerText || getElementText(row);
const subject = normalizeText(
row.querySelector('.n-thing-header__title')?.textContent
|| row.querySelector('h3, h4, h2')?.textContent
|| ''
);
const messageId = rowText.match(/ID:\s*([^\s]+)/i)?.[1] || null;
const timestampText = rowText.match(/\d{4}\/\d{1,2}\/\d{1,2}\s+\d{1,2}:\d{2}:\d{2}/)?.[0] || '';
const sender = normalizeText(rowText.match(/FROM:\s*([^\n]+)/i)?.[1] || '');
const matchedEmail = normalizeEmail(rowText.match(/TO:\s*([^\n]+)/i)?.[1] || '');
return {
combinedText: normalizeText(rowText),
emailTimestamp: null,
matchedEmail,
messageId,
row,
sender,
subject,
timestampText,
};
}
function findMessageDetailRoot() {
const deleteButton = Array.from(document.querySelectorAll('button'))
.find((button) => isVisible(button) && /^删除$/.test(normalizeText(getElementText(button))));
let current = deleteButton?.parentElement || null;
while (current && current !== document.body) {
const text = getElementText(current);
if (/FROM:/i.test(text) && /TO:/i.test(text)) {
return current;
}
current = current.parentElement;
}
return null;
}
async function openMessageRow(row, subject) {
await humanPause(80, 180);
simulateClick(row);
await waitForCondition(() => {
const detailRoot = findMessageDetailRoot();
const detailText = normalizeText(getElementText(detailRoot));
if (!detailText) return null;
if (!subject || detailText.includes(subject)) {
return detailRoot;
}
return null;
}, 4000, `Timed out opening message ${subject || ''}`.trim());
}
function buildMessageDetailText() {
return normalizeText(getElementText(findMessageDetailRoot()));
}
async function collectMessagesForTarget(targetEmail) {
const normalizedTargetEmail = normalizeEmail(targetEmail);
const rows = getMessageRows();
const messages = [];
for (const row of rows) {
const message = parseMessageRow(row);
if (!message.matchedEmail || message.matchedEmail !== normalizedTargetEmail) {
continue;
}
if (!extractVerificationCode(`${message.subject} ${message.combinedText}`)) {
await openMessageRow(row, message.subject);
message.combinedText = `${message.combinedText} ${buildMessageDetailText()}`.trim();
}
messages.push(message);
}
return messages;
}
async function runMailQuery(targetEmail) {
const queryInput = document.querySelector('input[placeholder="留空查询所有地址"]');
if (!queryInput) {
throw new Error('Could not find the admin mail query input.');
}
const queryButton = findVisibleButton(/^查询$/);
if (!queryButton) {
throw new Error('Could not find the admin mail query button.');
}
fillInput(queryInput, targetEmail);
await humanPause(80, 180);
simulateClick(queryButton);
await sleep(800);
}
async function refreshMailList() {
const refreshButton = findVisibleButton(/^刷新$/);
if (!refreshButton) {
throw new Error('Could not find the admin mail refresh button.');
}
simulateClick(refreshButton);
await sleep(1000);
}
async function pollCloudflareTempEmail(step, payload = {}) {
const {
filterAfterTimestamp = 0,
intervalMs = 3000,
maxAttempts = 20,
senderFilters = [],
subjectFilters = [],
targetEmail = '',
} = payload;
if (!targetEmail) {
throw new Error('No target email provided for Cloudflare Temp Email polling.');
}
await ensureMailPage();
await runMailQuery(targetEmail);
log(`Step ${step}: Starting Cloudflare Temp Email poll for ${targetEmail}`, 'info');
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
log(`Step ${step}: Polling Cloudflare Temp Email... attempt ${attempt}/${maxAttempts}`, 'info');
await refreshMailList();
const messages = await collectMessagesForTarget(targetEmail);
const match = selectVerificationMessage(messages, {
filterAfterTimestamp,
senderFilters,
subjectFilters,
targetEmail,
});
if (match?.code) {
log(
`Step ${step}: Code found: ${match.code} (subject: ${(match.subject || '').slice(0, 60)})`,
'ok'
);
return {
ok: true,
code: match.code,
emailTimestamp: match.emailTimestamp,
matchedEmail: match.matchedEmail,
messageId: match.messageId,
subject: match.subject,
};
}
if (attempt < maxAttempts) {
await sleep(intervalMs);
}
}
const newerSuffix = Number(filterAfterTimestamp) > 0
? ' newer than the previous verification message'
: '';
throw new Error(
`No matching verification email${newerSuffix} was found for ${targetEmail} after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s.`
);
}
}
@@ -0,0 +1,74 @@
// content/duck-mail.js — Content script for DuckDuckGo Email Protection autofill settings
console.log('[MultiPage:duck-mail] Content script loaded on', location.href);
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type !== 'FETCH_DUCK_EMAIL') return;
resetStopState();
fetchDuckEmail(message.payload).then(result => {
sendResponse(result);
}).catch(err => {
if (isStopError(err)) {
log('Duck Mail: Stopped by user.', 'warn');
sendResponse({ stopped: true, error: err.message });
return;
}
sendResponse({ error: err.message });
});
return true;
});
async function fetchDuckEmail(payload = {}) {
const { generateNew = true } = payload;
log(`Duck Mail: ${generateNew ? 'Generating' : 'Reading'} private address...`);
await waitForElement(
'input.AutofillSettingsPanel__PrivateDuckAddressValue, button.AutofillSettingsPanel__GeneratorButton',
15000
);
const getAddressInput = () => document.querySelector('input.AutofillSettingsPanel__PrivateDuckAddressValue');
const getGeneratorButton = () => document.querySelector('button.AutofillSettingsPanel__GeneratorButton')
|| Array.from(document.querySelectorAll('button')).find(btn => /generate private duck address/i.test(btn.textContent || ''));
const readEmail = () => {
const value = getAddressInput()?.value?.trim() || '';
return value.includes('@duck.com') ? value : '';
};
const waitForEmailValue = async (previousValue = '') => {
for (let i = 0; i < 100; i++) {
const nextValue = readEmail();
if (nextValue && nextValue !== previousValue) {
return nextValue;
}
await sleep(150);
}
throw new Error('Timed out waiting for Duck address to appear.');
};
const currentEmail = readEmail();
if (currentEmail && !generateNew) {
log(`Duck Mail: Found existing address ${currentEmail}`);
return { email: currentEmail, generated: false };
}
await humanPause(500, 1300);
const generatorButton = getGeneratorButton();
if (!generatorButton) {
if (currentEmail) {
log(`Duck Mail: Reusing existing address ${currentEmail}`, 'warn');
return { email: currentEmail, generated: false };
}
throw new Error('Could not find "Generate Private Duck Address" button.');
}
generatorButton.click();
log('Duck Mail: Clicked "Generate Private Duck Address"');
const nextEmail = await waitForEmailValue(currentEmail);
log(`Duck Mail: Ready address ${nextEmail}`, 'ok');
return { email: nextEmail, generated: true };
}
@@ -0,0 +1,258 @@
// content/inbucket-mail.js — Content script for Inbucket polling (steps 4, 7)
// Injected dynamically on the configured Inbucket host
//
// Supported page:
// - /m/<mailbox>/
const INBUCKET_PREFIX = '[MultiPage:inbucket-mail]';
const isTopFrame = window === window.top;
const SEEN_MAIL_IDS_KEY = 'seenInbucketMailIds';
console.log(INBUCKET_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
if (!isTopFrame) {
console.log(INBUCKET_PREFIX, 'Skipping child frame');
} else {
let seenMailIds = new Set();
async function loadSeenMailIds() {
try {
const data = await chrome.storage.session.get(SEEN_MAIL_IDS_KEY);
if (Array.isArray(data[SEEN_MAIL_IDS_KEY])) {
seenMailIds = new Set(data[SEEN_MAIL_IDS_KEY]);
console.log(INBUCKET_PREFIX, `Loaded ${seenMailIds.size} previously seen mail ids`);
}
} catch (err) {
console.warn(INBUCKET_PREFIX, 'Session storage unavailable, using in-memory seen mail ids:', err?.message || err);
}
}
async function persistSeenMailIds() {
try {
await chrome.storage.session.set({ [SEEN_MAIL_IDS_KEY]: [...seenMailIds] });
} catch (err) {
console.warn(INBUCKET_PREFIX, 'Could not persist seen mail ids, continuing in-memory only:', err?.message || err);
}
}
loadSeenMailIds();
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'POLL_EMAIL') {
resetStopState();
handlePollEmail(message.step, message.payload).then(result => {
sendResponse(result);
}).catch(err => {
if (isStopError(err)) {
log(`Step ${message.step}: Stopped by user.`, 'warn');
sendResponse({ stopped: true, error: err.message });
return;
}
reportError(message.step, err.message);
sendResponse({ error: err.message });
});
return true;
}
});
function normalizeText(value) {
return (value || '').replace(/\s+/g, ' ').trim().toLowerCase();
}
function extractVerificationCode(text) {
const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s:]*(\d{6})/);
if (matchCn) return matchCn[1];
const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
if (matchEn) return matchEn[1] || matchEn[2];
const match6 = text.match(/\b(\d{6})\b/);
if (match6) return match6[1];
return null;
}
function rowMatchesFilters(mail, senderFilters, subjectFilters, targetEmail) {
const sender = normalizeText(mail.sender);
const subject = normalizeText(mail.subject);
const mailbox = normalizeText(mail.mailbox);
const combined = normalizeText(mail.combinedText);
const targetLocal = normalizeText((targetEmail || '').split('@')[0]);
const senderMatch = senderFilters.some(f => sender.includes(f.toLowerCase()) || combined.includes(f.toLowerCase()));
const subjectMatch = subjectFilters.some(f => subject.includes(f.toLowerCase()) || combined.includes(f.toLowerCase()));
const mailboxMatch = Boolean(targetLocal) && mailbox.includes(targetLocal);
const forwardedDuck = /duckduckgo|forward(?:ed)?\s*by/i.test(mail.combinedText);
const code = extractVerificationCode(mail.combinedText);
const keywordMatch = /openai|chatgpt|verify|verification|confirm|login|验证码|代码/.test(combined);
if (mailboxMatch) return { matched: true, mailboxMatch, code };
if (senderMatch || subjectMatch) return { matched: true, mailboxMatch: false, code };
if (code && (forwardedDuck || keywordMatch)) return { matched: true, mailboxMatch: false, code };
return { matched: false, mailboxMatch: false, code };
}
function findMailboxEntries() {
return document.querySelectorAll('.message-list-entry');
}
function getMailboxEntryId(entry, index = 0) {
const explicitId = entry.getAttribute('data-id') || entry.dataset?.id || '';
if (explicitId) return explicitId;
const subject = entry.querySelector('.subject')?.textContent?.trim() || '';
const sender = entry.querySelector('.from')?.textContent?.trim() || '';
const dateText = entry.querySelector('.date')?.textContent?.trim() || '';
return `mailbox:${index}:${normalizeText(subject)}|${normalizeText(sender)}|${normalizeText(dateText)}`;
}
function parseMailboxEntry(entry, index = 0) {
const subject = entry.querySelector('.subject')?.textContent?.trim() || '';
const sender = entry.querySelector('.from')?.textContent?.trim() || '';
const dateText = entry.querySelector('.date')?.textContent?.trim() || '';
const combinedText = [subject, sender, dateText].filter(Boolean).join(' ');
return {
entry,
dateText,
sender,
mailbox: '',
subject,
unread: entry.classList.contains('unseen'),
combinedText,
mailId: getMailboxEntryId(entry, index),
};
}
function getCurrentMailboxIds() {
const ids = new Set();
Array.from(findMailboxEntries()).forEach((entry, index) => {
ids.add(getMailboxEntryId(entry, index));
});
return ids;
}
async function refreshMailbox() {
const refreshButton = document.querySelector('button[alt="Refresh Mailbox"]');
if (!refreshButton) return;
simulateClick(refreshButton);
await sleep(800);
}
async function openMailboxEntry(entry) {
simulateClick(entry);
for (let i = 0; i < 20; i++) {
if (entry.classList.contains('selected') || document.querySelector('.message-header, .message-body, .button-bar')) {
return;
}
await sleep(150);
}
}
async function deleteCurrentMailboxMessage(step) {
try {
const deleteButton = await waitForElement('.button-bar button.danger', 5000);
simulateClick(deleteButton);
log(`Step ${step}: Deleted mailbox message`, 'ok');
await sleep(1200);
} catch (err) {
log(`Step ${step}: Failed to delete mailbox message: ${err.message}`, 'warn');
}
}
async function handleMailboxPollEmail(step, payload) {
const {
senderFilters = [],
subjectFilters = [],
maxAttempts = 20,
intervalMs = 3000,
} = payload || {};
log(`Step ${step}: Starting email poll on Inbucket mailbox page (max ${maxAttempts} attempts)`);
try {
await waitForElement('.message-list, .message-list-entry', 15000);
log(`Step ${step}: Mailbox page loaded`);
} catch {
throw new Error('Inbucket mailbox page did not load. Make sure /m/<mailbox>/ is open.');
}
const existingMailIds = getCurrentMailboxIds();
log(`Step ${step}: Snapshotted ${existingMailIds.size} existing mailbox messages`);
const FALLBACK_AFTER = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
log(`Polling Inbucket mailbox... attempt ${attempt}/${maxAttempts}`);
if (attempt > 1) {
await refreshMailbox();
}
const entries = Array.from(findMailboxEntries()).map(parseMailboxEntry);
const useFallback = attempt > FALLBACK_AFTER;
const candidates = [];
for (const mail of entries) {
if (!mail.unread) continue;
if (seenMailIds.has(mail.mailId)) continue;
if (!useFallback && existingMailIds.has(mail.mailId)) continue;
const match = rowMatchesFilters(mail, senderFilters, subjectFilters, '');
if (!match.matched) continue;
candidates.push({ ...mail, code: match.code });
}
for (const mail of candidates) {
const code = mail.code || extractVerificationCode(mail.combinedText);
if (!code) continue;
await openMailboxEntry(mail.entry);
await deleteCurrentMailboxMessage(step);
seenMailIds.add(mail.mailId);
await persistSeenMailIds();
const source = existingMailIds.has(mail.mailId) ? 'fallback' : 'new';
log(
`Step ${step}: Code found: ${code} (${source}, sender: ${mail.sender || 'unknown'}, subject: ${(mail.subject || '').slice(0, 60)})`,
'ok'
);
return {
ok: true,
code,
emailTimestamp: Date.now(),
mailId: mail.mailId,
};
}
if (attempt === FALLBACK_AFTER + 1) {
log(`Step ${step}: No new mailbox messages yet, falling back to older matching messages`, 'warn');
}
if (attempt < maxAttempts) {
await sleep(intervalMs);
}
}
throw new Error(
`No matching verification email found in Inbucket mailbox after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s. ` +
'Check the mailbox page manually.'
);
}
async function handlePollEmail(step, payload) {
if (!location.pathname.startsWith('/m/')) {
throw new Error('Inbucket now only supports mailbox pages like /m/<mailbox>/.');
}
return handleMailboxPollEmail(step, payload);
}
} // end of isTopFrame else block
@@ -0,0 +1,296 @@
// content/mail-163.js — Content script for 163 Mail (steps 4, 7)
// Injected on: mail.163.com
//
// DOM structure:
// Mail item: div[sign="letter"] with aria-label="你的 ChatGPT 代码为 479637 发件人 OpenAI ..."
// Sender: .nui-user (e.g., "OpenAI")
// Subject: span.da0 (e.g., "你的 ChatGPT 代码为 479637")
// Right-click menu: .nui-menu → .nui-menu-item with text "删除邮件"
const MAIL163_PREFIX = '[MultiPage:mail-163]';
const isTopFrame = window === window.top;
console.log(MAIL163_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
// Only operate in the top frame
if (!isTopFrame) {
console.log(MAIL163_PREFIX, 'Skipping child frame');
} else {
// Track codes we've already seen — persisted in chrome.storage.session to survive script re-injection
let seenCodes = new Set();
async function loadSeenCodes() {
try {
const data = await chrome.storage.session.get('seenCodes');
if (data.seenCodes && Array.isArray(data.seenCodes)) {
seenCodes = new Set(data.seenCodes);
console.log(MAIL163_PREFIX, `Loaded ${seenCodes.size} previously seen codes`);
}
} catch (err) {
console.warn(MAIL163_PREFIX, 'Session storage unavailable, using in-memory seen codes:', err?.message || err);
}
}
// Load previously seen codes on startup
loadSeenCodes();
async function persistSeenCodes() {
try {
await chrome.storage.session.set({ seenCodes: [...seenCodes] });
} catch (err) {
console.warn(MAIL163_PREFIX, 'Could not persist seen codes, continuing in-memory only:', err?.message || err);
}
}
// ============================================================
// Message Handler (top frame only)
// ============================================================
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'POLL_EMAIL') {
resetStopState();
handlePollEmail(message.step, message.payload).then(result => {
sendResponse(result);
}).catch(err => {
if (isStopError(err)) {
log(`Step ${message.step}: Stopped by user.`, 'warn');
sendResponse({ stopped: true, error: err.message });
return;
}
reportError(message.step, err.message);
sendResponse({ error: err.message });
});
return true;
}
});
// ============================================================
// Find mail items
// ============================================================
function findMailItems() {
return document.querySelectorAll('div[sign="letter"]');
}
function getCurrentMailIds() {
const ids = new Set();
findMailItems().forEach(item => {
const id = item.getAttribute('id') || '';
if (id) ids.add(id);
});
return ids;
}
// ============================================================
// Email Polling
// ============================================================
async function handlePollEmail(step, payload) {
const { senderFilters, subjectFilters, maxAttempts, intervalMs } = payload;
log(`Step ${step}: Starting email poll on 163 Mail (max ${maxAttempts} attempts)`);
// Click inbox in sidebar to ensure we're in inbox view
log(`Step ${step}: Waiting for sidebar...`);
try {
const inboxLink = await waitForElement('.nui-tree-item-text[title="收件箱"]', 5000);
inboxLink.click();
log(`Step ${step}: Clicked inbox`);
} catch {
log(`Step ${step}: Inbox link not found, proceeding...`, 'warn');
}
// Wait for mail list to appear
log(`Step ${step}: Waiting for mail list...`);
let items = [];
for (let i = 0; i < 20; i++) {
items = findMailItems();
if (items.length > 0) break;
await sleep(500);
}
if (items.length === 0) {
await refreshInbox();
await sleep(2000);
items = findMailItems();
}
if (items.length === 0) {
throw new Error('163 Mail list did not load. Make sure inbox is open.');
}
log(`Step ${step}: Mail list loaded, ${items.length} items`);
// Snapshot existing mail IDs
const existingMailIds = getCurrentMailIds();
log(`Step ${step}: Snapshotted ${existingMailIds.size} existing emails`);
const FALLBACK_AFTER = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
log(`Polling 163 Mail... attempt ${attempt}/${maxAttempts}`);
if (attempt > 1) {
await refreshInbox();
await sleep(1000);
}
const allItems = findMailItems();
const useFallback = attempt > FALLBACK_AFTER;
for (const item of allItems) {
const id = item.getAttribute('id') || '';
if (!useFallback && existingMailIds.has(id)) continue;
const senderEl = item.querySelector('.nui-user');
const sender = senderEl ? senderEl.textContent.toLowerCase() : '';
const subjectEl = item.querySelector('span.da0');
const subject = subjectEl ? subjectEl.textContent : '';
const ariaLabel = (item.getAttribute('aria-label') || '').toLowerCase();
const senderMatch = senderFilters.some(f => sender.includes(f.toLowerCase()) || ariaLabel.includes(f.toLowerCase()));
const subjectMatch = subjectFilters.some(f => subject.toLowerCase().includes(f.toLowerCase()) || ariaLabel.includes(f.toLowerCase()));
if (senderMatch || subjectMatch) {
const code = extractVerificationCode(subject + ' ' + ariaLabel);
if (code && !seenCodes.has(code)) {
seenCodes.add(code);
persistSeenCodes();
const source = useFallback && existingMailIds.has(id) ? 'fallback' : 'new';
log(`Step ${step}: Code found: ${code} (${source}, subject: ${subject.slice(0, 40)})`, 'ok');
// Delete this email via right-click menu, WAIT for it to finish before returning
await deleteEmail(item, step);
// Extra wait to ensure deletion is processed
await sleep(1000);
return { ok: true, code, emailTimestamp: Date.now(), mailId: id };
} else if (code && seenCodes.has(code)) {
log(`Step ${step}: Skipping already-seen code: ${code}`, 'info');
}
}
}
if (attempt === FALLBACK_AFTER + 1) {
log(`Step ${step}: No new emails after ${FALLBACK_AFTER} attempts, falling back to first match`, 'warn');
}
if (attempt < maxAttempts) {
await sleep(intervalMs);
}
}
throw new Error(
`No new matching email found on 163 Mail after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s. ` +
'Check inbox manually.'
);
}
// ============================================================
// Delete Email via Right-Click Menu
// ============================================================
async function deleteEmail(item, step) {
try {
log(`Step ${step}: Deleting email...`);
// Strategy 1: Click the trash icon inside the mail item
// Each mail item has: <b class="nui-ico nui-ico-delete" title="删除邮件" sign="trash">
// These icons appear on hover, so we trigger mouseover first
item.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
item.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
await sleep(300);
const trashIcon = item.querySelector('[sign="trash"], .nui-ico-delete, [title="删除邮件"]');
if (trashIcon) {
trashIcon.click();
log(`Step ${step}: Clicked trash icon`, 'ok');
await sleep(1500);
// Check if item disappeared (confirm deletion)
const stillExists = document.getElementById(item.id);
if (!stillExists || stillExists.style.display === 'none') {
log(`Step ${step}: Email deleted successfully`);
} else {
log(`Step ${step}: Email may not have been deleted, item still visible`, 'warn');
}
return;
}
// Strategy 2: Select checkbox then click toolbar delete button
log(`Step ${step}: Trash icon not found, trying checkbox + toolbar delete...`);
const checkbox = item.querySelector('[sign="checkbox"], .nui-chk');
if (checkbox) {
checkbox.click();
await sleep(300);
// Click toolbar delete button
const toolbarBtns = document.querySelectorAll('.nui-btn .nui-btn-text');
for (const btn of toolbarBtns) {
if (btn.textContent.replace(/\s/g, '').includes('删除')) {
btn.closest('.nui-btn').click();
log(`Step ${step}: Clicked toolbar delete`, 'ok');
await sleep(1500);
return;
}
}
}
log(`Step ${step}: Could not delete email (no delete button found)`, 'warn');
} catch (err) {
log(`Step ${step}: Failed to delete email: ${err.message}`, 'warn');
}
}
// ============================================================
// Inbox Refresh
// ============================================================
async function refreshInbox() {
// Try toolbar "刷 新" button
const toolbarBtns = document.querySelectorAll('.nui-btn .nui-btn-text');
for (const btn of toolbarBtns) {
if (btn.textContent.replace(/\s/g, '') === '刷新') {
btn.closest('.nui-btn').click();
console.log(MAIL163_PREFIX, 'Clicked "刷新" button');
await sleep(800);
return;
}
}
// Fallback: click sidebar "收 信"
const shouXinBtns = document.querySelectorAll('.ra0');
for (const btn of shouXinBtns) {
if (btn.textContent.replace(/\s/g, '').includes('收信')) {
btn.click();
console.log(MAIL163_PREFIX, 'Clicked "收信" button');
await sleep(800);
return;
}
}
console.log(MAIL163_PREFIX, 'Could not find refresh button');
}
// ============================================================
// Verification Code Extraction
// ============================================================
function extractVerificationCode(text) {
const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s:]*(\d{6})/);
if (matchCn) return matchCn[1];
const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
if (matchEn) return matchEn[1] || matchEn[2];
const match6 = text.match(/\b(\d{6})\b/);
if (match6) return match6[1];
return null;
}
} // end of isTopFrame else block
@@ -0,0 +1,147 @@
// content/qq-mail.js — Content script for QQ Mail (steps 4, 7)
// Injected on: mail.qq.com, wx.mail.qq.com
// NOTE: all_frames: true
//
// Strategy for avoiding stale codes:
// 1. On poll start, snapshot all existing mail IDs as "old"
// 2. On each poll cycle, refresh inbox and look for NEW items (not in snapshot)
// 3. Only extract codes from NEW items that match sender/subject filters
// 4. Never fall back to older matching emails
const QQ_MAIL_PREFIX = '[MultiPage:qq-mail]';
const isTopFrame = window === window.top;
console.log(QQ_MAIL_PREFIX, 'Content script loaded on', location.href, 'frame:', isTopFrame ? 'top' : 'child');
// ============================================================
// Message Handler
// ============================================================
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'POLL_EMAIL') {
if (!isTopFrame) {
sendResponse({ ok: false, reason: 'wrong-frame' });
return;
}
resetStopState();
handlePollEmail(message.step, message.payload).then(result => {
sendResponse(result);
}).catch(err => {
if (isStopError(err)) {
log(`Step ${message.step}: Stopped by user.`, 'warn');
sendResponse({ stopped: true, error: err.message });
return;
}
reportError(message.step, err.message);
sendResponse({ error: err.message });
});
return true; // async response
}
});
// ============================================================
// Get all current mail IDs from the list
// ============================================================
function getCurrentMailIds() {
const ids = new Set();
document.querySelectorAll('.mail-list-page-item[data-mailid]').forEach(item => {
ids.add(item.getAttribute('data-mailid'));
});
return ids;
}
function collectMailItems() {
return Array.from(document.querySelectorAll('.mail-list-page-item[data-mailid]')).map((item) => ({
mailId: item.getAttribute('data-mailid') || '',
sender: item.querySelector('.cmp-account-nick')?.textContent || '',
subject: item.querySelector('.mail-subject')?.textContent || '',
digest: item.querySelector('.mail-digest')?.textContent || '',
}));
}
// ============================================================
// Email Polling
// ============================================================
async function handlePollEmail(step, payload) {
const { senderFilters, subjectFilters, maxAttempts, intervalMs } = payload;
log(`Step ${step}: Starting email poll (max ${maxAttempts} attempts, every ${intervalMs / 1000}s)`);
// Wait for mail list to load
try {
await waitForElement('.mail-list-page-item', 10000);
log(`Step ${step}: Mail list loaded`);
} catch {
throw new Error('Mail list did not load. Make sure QQ Mail inbox is open.');
}
// Step 1: Snapshot existing mail IDs BEFORE we start waiting for new email
const existingMailIds = getCurrentMailIds();
log(`Step ${step}: Snapshotted ${existingMailIds.size} existing emails as "old"`);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
log(`Polling QQ Mail... attempt ${attempt}/${maxAttempts}`);
// Refresh inbox (skip on first attempt, list is fresh)
if (attempt > 1) {
await refreshInbox();
await sleep(800);
}
const result = MultiPageQQMail.findNewQQVerificationCode(collectMailItems(), {
existingMailIds: [...existingMailIds],
senderFilters,
subjectFilters,
});
if (result) {
log(`Step ${step}: Code found: ${result.code} (${result.source}, subject: ${result.subject.slice(0, 40)})`, 'ok');
return { ok: true, code: result.code, emailTimestamp: Date.now(), mailId: result.mailId };
}
if (attempt < maxAttempts) {
await sleep(intervalMs);
}
}
throw new Error(
`No new matching email found after ${(maxAttempts * intervalMs / 1000).toFixed(0)}s. ` +
'Check QQ Mail manually. Email may be delayed or in spam folder.'
);
}
// ============================================================
// Inbox Refresh
// ============================================================
async function refreshInbox() {
// Try multiple strategies to refresh the mail list
// Strategy 1: Click any visible refresh button
const refreshBtn = document.querySelector('[class*="refresh"], [title*="刷新"]');
if (refreshBtn) {
simulateClick(refreshBtn);
console.log(QQ_MAIL_PREFIX, 'Clicked refresh button');
await sleep(500);
return;
}
// Strategy 2: Click inbox in sidebar to reload list
const sidebarInbox = document.querySelector('a[href*="inbox"], [class*="folder-item"][class*="inbox"], [title="收件箱"]');
if (sidebarInbox) {
simulateClick(sidebarInbox);
console.log(QQ_MAIL_PREFIX, 'Clicked sidebar inbox');
await sleep(500);
return;
}
// Strategy 3: Click the folder name in toolbar
const folderName = document.querySelector('.toolbar-folder-name');
if (folderName) {
simulateClick(folderName);
console.log(QQ_MAIL_PREFIX, 'Clicked toolbar folder name');
await sleep(500);
}
}
@@ -0,0 +1,265 @@
// content/relay-firefox.js — Content script for Firefox Relay profile page
console.log('[MultiPage:relay-firefox] Content script loaded on', location.href);
const {
getNextRelayMaskLabel = (labels = []) => `t${labels.length + 1}`,
} = globalThis.MultiPageEmailProvider || {};
const LABEL_INPUT_SELECTOR = 'input[placeholder="Add account name"], input[aria-label="Edit the label for this mask"]';
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type !== 'CREATE_RELAY_MASK' && message.type !== 'DELETE_RELAY_MASK') return;
resetStopState();
const handler = message.type === 'CREATE_RELAY_MASK'
? createRelayMask
: deleteRelayMask;
handler(message.payload || {}).then(result => {
sendResponse(result);
}).catch(err => {
if (isStopError(err)) {
log('Relay: Stopped by user.', 'warn');
sendResponse({ stopped: true, error: err.message });
return;
}
sendResponse({ error: err.message });
});
return true;
});
function getElementText(el) {
return [
el?.innerText,
el?.textContent,
el?.getAttribute?.('aria-label'),
el?.getAttribute?.('title'),
el?.getAttribute?.('description'),
].filter(Boolean).join(' ');
}
function isVisible(el) {
if (!el) return false;
if (el.hidden) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return false;
}
return Boolean(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
}
function extractMozmail(text) {
const match = String(text || '').match(/[A-Z0-9._%+-]+@mozmail\.com/i);
return match ? match[0].toLowerCase() : '';
}
function getMaskButtons(root = document) {
return Array.from(root.querySelectorAll('button')).filter((button) => extractMozmail(getElementText(button)));
}
function getMaskEmails(root = document) {
return Array.from(new Set(
getMaskButtons(root)
.map((button) => extractMozmail(getElementText(button)))
.filter(Boolean)
));
}
function getVisibleLabelInputs(root = document) {
return Array.from(root.querySelectorAll(LABEL_INPUT_SELECTOR)).filter(isVisible);
}
function getExistingLabels() {
return Array.from(document.querySelectorAll(LABEL_INPUT_SELECTOR))
.map((input) => input.value.trim())
.filter(Boolean);
}
function findGenerateButton() {
return document.querySelector('button[title="Generate new mask"]')
|| Array.from(document.querySelectorAll('button')).find((button) => /generate new mask/i.test(getElementText(button)));
}
function findDeleteButton(root) {
return Array.from(root.querySelectorAll('button')).find((button) => /^delete$/i.test(getElementText(button).trim()));
}
function getMaskButtonsIn(root) {
return Array.from(root.querySelectorAll('button')).filter((button) => extractMozmail(getElementText(button)));
}
function findMaskContainerForButton(button) {
let current = button?.parentElement || null;
while (current && current !== document.body) {
const maskButtons = getMaskButtonsIn(current);
if (maskButtons.length === 1 && (current.querySelector(LABEL_INPUT_SELECTOR) || findDeleteButton(current))) {
return current;
}
current = current.parentElement;
}
return button?.closest('li') || button?.parentElement || null;
}
function findMaskRowByEmail(email) {
const normalizedEmail = String(email || '').toLowerCase();
const button = getMaskButtons().find((candidate) => extractMozmail(getElementText(candidate)) === normalizedEmail);
if (!button) return null;
return findMaskContainerForButton(button);
}
async function waitForNewMaskEmail(previousEmails = new Set(), timeout = 15000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeout) {
throwIfStopped();
const currentEmails = getMaskEmails();
const nextEmail = currentEmails.find((email) => !previousEmails.has(email));
if (nextEmail) {
return nextEmail;
}
await sleep(150);
}
throw new Error('Timed out waiting for a new Relay mask to appear.');
}
async function waitForMaskRow(email, timeout = 10000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeout) {
throwIfStopped();
const row = findMaskRowByEmail(email);
if (row) {
return row;
}
await sleep(150);
}
throw new Error(`Timed out waiting for Relay mask row: ${email}`);
}
async function assignRelayLabel(maskRow) {
const labelInput = Array.from(maskRow.querySelectorAll(LABEL_INPUT_SELECTOR)).find(isVisible)
|| maskRow.querySelector(LABEL_INPUT_SELECTOR);
if (!labelInput) {
throw new Error('Could not find Relay label input for the new mask.');
}
const currentValue = labelInput.value.trim();
if (currentValue) {
return currentValue;
}
const nextLabel = getNextRelayMaskLabel(getExistingLabels());
await humanPause(200, 450);
fillInput(labelInput, nextLabel);
labelInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
labelInput.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', bubbles: true }));
labelInput.blur();
for (let i = 0; i < 20; i++) {
throwIfStopped();
const labels = getExistingLabels();
if (labels.includes(nextLabel) || labelInput.value.trim() === nextLabel) {
log(`Relay: Assigned label ${nextLabel}`, 'ok');
return nextLabel;
}
await sleep(150);
}
throw new Error(`Relay label ${nextLabel} was not saved.`);
}
async function createRelayMask(payload = {}) {
const { generateNew = true } = payload;
log(`Relay: ${generateNew ? 'Creating' : 'Reading'} mask...`);
await waitForElement(LABEL_INPUT_SELECTOR + ', button[title="Generate new mask"]', 20000);
const previousEmails = new Set(getMaskEmails());
if (!generateNew && previousEmails.size > 0) {
const email = Array.from(previousEmails)[0];
return { email, label: null, generated: false };
}
const generatorButton = findGenerateButton();
if (!generatorButton) {
throw new Error('Could not find "Generate new mask" button on Firefox Relay.');
}
await humanPause(500, 1200);
simulateClick(generatorButton);
log('Relay: Clicked "Generate new mask"');
const email = await waitForNewMaskEmail(previousEmails);
const maskRow = await waitForMaskRow(email);
const label = await assignRelayLabel(maskRow);
log(`Relay: Ready mask ${email}`, 'ok');
return { email, label, generated: true };
}
function findVisibleDialogDeleteButton() {
const dialogButtons = Array.from(document.querySelectorAll('[role="dialog"] button, dialog button, [aria-modal="true"] button'));
return dialogButtons.find((button) => isVisible(button) && /delete|confirm|remove/i.test(getElementText(button)));
}
async function waitForMaskRemoval(email, timeout = 15000) {
const startedAt = Date.now();
const normalizedEmail = String(email || '').toLowerCase();
while (Date.now() - startedAt < timeout) {
throwIfStopped();
const exists = getMaskEmails().includes(normalizedEmail);
if (!exists) {
return;
}
await sleep(200);
}
throw new Error(`Timed out waiting for Relay mask deletion: ${email}`);
}
async function deleteRelayMask(payload = {}) {
const email = String(payload.email || '').trim().toLowerCase();
if (!email) {
throw new Error('No Relay mask email provided for deletion.');
}
log(`Relay: Deleting ${email}...`);
await waitForElement(LABEL_INPUT_SELECTOR + ', button[title="Generate new mask"]', 20000);
const maskRow = await waitForMaskRow(email);
const detailsButton = Array.from(maskRow.querySelectorAll('button')).find((button) => /show mask details/i.test(getElementText(button)));
if (detailsButton && isVisible(detailsButton)) {
await humanPause(150, 300);
simulateClick(detailsButton);
await sleep(250);
}
const deleteButton = findDeleteButton(maskRow);
if (!deleteButton) {
throw new Error(`Could not find Delete button for Relay mask ${email}.`);
}
await humanPause(200, 400);
simulateClick(deleteButton);
await sleep(300);
const confirmButton = findVisibleDialogDeleteButton();
if (confirmButton) {
await humanPause(150, 300);
simulateClick(confirmButton);
}
await waitForMaskRemoval(email);
log(`Relay: Deleted ${email}`, 'ok');
return { deleted: true, email };
}
@@ -0,0 +1,569 @@
// content/signup-page.js — Content script for OpenAI auth pages (steps 2, 3, 4-receive, 5)
// Injected on: auth0.openai.com, auth.openai.com, accounts.openai.com
console.log('[MultiPage:signup-page] Content script loaded on', location.href);
// Listen for commands from Background
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'GET_PAGE_STATE') {
handleCommand(message).then((result) => {
sendResponse({ ok: true, ...(result || {}) });
}).catch(err => {
sendResponse({ error: err.message });
});
return true;
}
if (message.type === 'EXECUTE_STEP' || message.type === 'FILL_CODE' || message.type === 'STEP8_FIND_AND_CLICK') {
resetStopState();
handleCommand(message).then((result) => {
sendResponse({ ok: true, ...(result || {}) });
}).catch(err => {
if (isStopError(err)) {
log(`Step ${message.step || 8}: Stopped by user.`, 'warn');
sendResponse({ stopped: true, error: err.message });
return;
}
if (message.type === 'STEP8_FIND_AND_CLICK') {
log(`Step 8: ${err.message}`, 'error');
sendResponse({ error: err.message });
return;
}
reportError(message.step, err.message);
sendResponse({ error: err.message });
});
return true;
}
});
async function handleCommand(message) {
switch (message.type) {
case 'GET_PAGE_STATE':
return getCurrentPageState();
case 'EXECUTE_STEP':
switch (message.step) {
case 2: return await step2_clickRegister();
case 3: return await step3_fillEmailPassword(message.payload);
case 5: return await step5_fillNameBirthday(message.payload);
case 6: return await step6_login(message.payload);
case 8: return await step8_findAndClick();
default: throw new Error(`signup-page.js does not handle step ${message.step}`);
}
case 'FILL_CODE':
// Step 4 = signup code, Step 7 = login code (same handler)
return await fillVerificationCode(message.step, message.payload);
case 'STEP8_FIND_AND_CLICK':
return await step8_findAndClick();
}
}
function getCurrentPageState() {
const consentButton = findVisibleConsentButton();
const hasVisibleContinueButton = Boolean(consentButton);
return {
url: location.href,
hasVisibleContinueButton,
isConsentPage: MultiPageOAuthFlow.isConsentPageState({
url: location.href,
hasVisibleContinueButton,
}),
};
}
// ============================================================
// Step 2: Click Register
// ============================================================
async function step2_clickRegister() {
log('Step 2: Looking for Register/Sign up button...');
let registerBtn = null;
try {
registerBtn = await waitForElementByText(
'a, button, [role="button"], [role="link"]',
/sign\s*up|register|create\s*account|注册/i,
10000
);
} catch {
// Some pages may have a direct link
try {
registerBtn = await waitForElement('a[href*="signup"], a[href*="register"]', 5000);
} catch {
throw new Error(
'Could not find Register/Sign up button. ' +
'Check auth page DOM in DevTools. URL: ' + location.href
);
}
}
await humanPause(450, 1200);
reportComplete(2);
simulateClick(registerBtn);
log('Step 2: Clicked Register button');
}
// ============================================================
// Step 3: Fill Email & Password
// ============================================================
async function step3_fillEmailPassword(payload) {
const { email } = payload;
if (!email) throw new Error('No email provided. Paste email in Side Panel first.');
log(`Step 3: Filling email: ${email}`);
// Find email input
let emailInput = null;
try {
emailInput = await waitForElement(
'input[type="email"], input[name="email"], input[name="username"], input[id*="email"], input[placeholder*="email"], input[placeholder*="Email"]',
10000
);
} catch {
throw new Error('Could not find email input field on signup page. URL: ' + location.href);
}
await humanPause(500, 1400);
fillInput(emailInput, email);
log('Step 3: Email filled');
// Check if password field is on the same page
let passwordInput = document.querySelector('input[type="password"]');
if (!passwordInput) {
// Need to submit email first to get to password page
log('Step 3: No password field yet, submitting email first...');
const submitBtn = document.querySelector('button[type="submit"]')
|| await waitForElementByText('button', /continue|next|submit|继续|下一步/i, 5000).catch(() => null);
if (submitBtn) {
await humanPause(400, 1100);
simulateClick(submitBtn);
log('Step 3: Submitted email, waiting for password field...');
await sleep(2000);
}
try {
passwordInput = await waitForElement('input[type="password"]', 10000);
} catch {
throw new Error('Could not find password input after submitting email. URL: ' + location.href);
}
}
if (!payload.password) throw new Error('No password provided. Step 3 requires a generated password.');
await humanPause(600, 1500);
fillInput(passwordInput, payload.password);
log('Step 3: Password filled');
// Report complete BEFORE submit, because submit causes page navigation
// which kills the content script connection
reportComplete(3, { email });
// Submit the form (page will navigate away after this)
await sleep(500);
const submitBtn = document.querySelector('button[type="submit"]')
|| await waitForElementByText('button', /continue|sign\s*up|submit|注册|创建|create/i, 5000).catch(() => null);
if (submitBtn) {
await humanPause(500, 1300);
simulateClick(submitBtn);
log('Step 3: Form submitted');
}
}
// ============================================================
// Fill Verification Code (used by step 4 and step 7)
// ============================================================
async function fillVerificationCode(step, payload) {
const { code } = payload;
if (!code) throw new Error('No verification code provided.');
log(`Step ${step}: Filling verification code: ${code}`);
// Find code input — could be a single input or multiple separate inputs
let codeInput = null;
try {
codeInput = await waitForElement(
'input[name="code"], input[name="otp"], input[type="text"][maxlength="6"], input[aria-label*="code"], input[placeholder*="code"], input[placeholder*="Code"], input[inputmode="numeric"]',
10000
);
} catch {
// Check for multiple single-digit inputs (common pattern)
const singleInputs = document.querySelectorAll('input[maxlength="1"]');
if (singleInputs.length >= 6) {
log(`Step ${step}: Found single-digit code inputs, filling individually...`);
for (let i = 0; i < 6 && i < singleInputs.length; i++) {
fillInput(singleInputs[i], code[i]);
await sleep(100);
}
await sleep(1000);
reportComplete(step);
return;
}
throw new Error('Could not find verification code input. URL: ' + location.href);
}
fillInput(codeInput, code);
log(`Step ${step}: Code filled`);
// Report complete BEFORE submit (page may navigate away)
reportComplete(step);
// Submit
await sleep(500);
const submitBtn = document.querySelector('button[type="submit"]')
|| await waitForElementByText('button', /verify|confirm|submit|continue|确认|验证/i, 5000).catch(() => null);
if (submitBtn) {
await humanPause(450, 1200);
simulateClick(submitBtn);
log(`Step ${step}: Verification submitted`);
}
}
// ============================================================
// Step 6: Login with registered account (on OAuth auth page)
// ============================================================
async function step6_login(payload) {
const { email, password } = payload;
if (!email) throw new Error('No email provided for login.');
log(`Step 6: Logging in with ${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('Could not find email input on login page. URL: ' + location.href);
}
await humanPause(500, 1400);
fillInput(emailInput, email);
log('Step 6: Email filled');
// 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('Step 6: Submitted email');
}
const passwordInput = await waitForLoginPasswordField();
if (passwordInput) {
log('Step 6: Password field found, filling password...');
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('Step 6: Submitted password, may need verification code (step 7)');
}
return;
}
// No password field — OTP flow
log('Step 6: No password field. OTP flow or auto-redirect.');
reportComplete(6, { needsOTP: true });
}
async function waitForLoginPasswordField(timeout = 25000) {
const start = Date.now();
while (Date.now() - start < timeout) {
throwIfStopped();
const passwordInput = findVisiblePasswordInput();
if (passwordInput) {
return passwordInput;
}
await sleep(250);
}
log(`Step 6: Password field did not appear within ${Math.round(timeout / 1000)}s.`, 'warn');
return null;
}
function findVisiblePasswordInput() {
const inputs = document.querySelectorAll('input[type="password"]');
for (const input of inputs) {
if (isElementVisible(input)) {
return input;
}
}
return null;
}
function isElementVisible(el) {
if (!el) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return false;
}
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
// ============================================================
// Step 8: Find "继续" on OAuth consent page for debugger click
// ============================================================
// After login + verification, page shows:
// "使用 ChatGPT 登录到 Codex" with a "继续" submit button.
// Background performs the actual click through the debugger Input API.
async function step8_findAndClick() {
log('Step 8: Looking for OAuth consent "继续" button...');
const continueBtn = await findContinueButton();
await waitForButtonEnabled(continueBtn);
await humanPause(350, 900);
continueBtn.scrollIntoView({ behavior: 'smooth', block: 'center' });
continueBtn.focus();
await sleep(250);
const rect = getSerializableRect(continueBtn);
log('Step 8: Found "继续" button and prepared debugger click coordinates.');
return {
rect,
buttonText: (continueBtn.textContent || '').trim(),
url: location.href,
};
}
async function findContinueButton() {
const visibleButton = findVisibleConsentButton();
if (visibleButton) {
return visibleButton;
}
try {
return await waitForElement(
'button[type="submit"][data-dd-action-name="Continue"], button[type="submit"]._primary_3rdp0_107',
10000
);
} catch {
try {
return await waitForElementByText('button', /继续|Continue/, 5000);
} catch {
throw new Error('Could not find "继续" button on OAuth consent page. URL: ' + location.href);
}
}
}
function findVisibleConsentButton() {
const selectorMatches = document.querySelectorAll(
'button[type="submit"][data-dd-action-name="Continue"], button[type="submit"]._primary_3rdp0_107'
);
for (const button of selectorMatches) {
if (isElementVisible(button)) {
return button;
}
}
const buttons = document.querySelectorAll('button');
for (const button of buttons) {
if (!isElementVisible(button)) {
continue;
}
if (/继续|Continue/i.test(button.textContent || '')) {
return button;
}
}
return null;
}
async function waitForButtonEnabled(button, timeout = 8000) {
const start = Date.now();
while (Date.now() - start < timeout) {
throwIfStopped();
if (isButtonEnabled(button)) return;
await sleep(150);
}
throw new Error('"继续" button stayed disabled for too long. URL: ' + location.href);
}
function isButtonEnabled(button) {
return Boolean(button)
&& !button.disabled
&& button.getAttribute('aria-disabled') !== 'true';
}
function getSerializableRect(el) {
const rect = el.getBoundingClientRect();
if (!rect.width || !rect.height) {
throw new Error('"继续" button has no clickable size after scrolling. URL: ' + location.href);
}
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
centerX: rect.left + (rect.width / 2),
centerY: rect.top + (rect.height / 2),
};
}
// ============================================================
// Step 5: Fill Name & Birthday / Age
// ============================================================
async function step5_fillNameBirthday(payload) {
const { firstName, lastName, age, year, month, day } = payload;
if (!firstName || !lastName) throw new Error('No name data provided.');
const resolvedAge = age ?? (year ? new Date().getFullYear() - Number(year) : null);
const hasBirthdayData = [year, month, day].every(value => value != null && !Number.isNaN(Number(value)));
if (!hasBirthdayData && (resolvedAge == null || Number.isNaN(Number(resolvedAge)))) {
throw new Error('No birthday or age data provided.');
}
const fullName = `${firstName} ${lastName}`;
log(`Step 5: Filling name: ${fullName}`);
// Actual DOM structure:
// - Full name: <input name="name" placeholder="全名" type="text">
// - Birthday: React Aria DateField or hidden input[name="birthday"]
// - Age: <input name="age" type="text|number">
// --- Full Name (single field, not first+last) ---
let nameInput = null;
try {
nameInput = await waitForElement(
'input[name="name"], input[placeholder*="全名"], input[autocomplete="name"]',
10000
);
} catch {
throw new Error('Could not find name input. URL: ' + location.href);
}
await humanPause(500, 1300);
fillInput(nameInput, fullName);
log(`Step 5: Name filled: ${fullName}`);
let birthdayMode = false;
let ageInput = null;
for (let i = 0; i < 100; i++) {
const yearSpinner = document.querySelector('[role="spinbutton"][data-type="year"]');
const monthSpinner = document.querySelector('[role="spinbutton"][data-type="month"]');
const daySpinner = document.querySelector('[role="spinbutton"][data-type="day"]');
const hiddenBirthday = document.querySelector('input[name="birthday"]');
ageInput = document.querySelector('input[name="age"]');
// Some pages include a hidden birthday input even though the real UI is "age".
// In that case we must prioritize filling age to satisfy required validation.
if (ageInput) break;
if ((yearSpinner && monthSpinner && daySpinner) || hiddenBirthday) {
birthdayMode = true;
break;
}
await sleep(100);
}
if (birthdayMode) {
if (!hasBirthdayData) {
throw new Error('Birthday field detected, but no birthday data provided.');
}
const yearSpinner = document.querySelector('[role="spinbutton"][data-type="year"]');
const monthSpinner = document.querySelector('[role="spinbutton"][data-type="month"]');
const daySpinner = document.querySelector('[role="spinbutton"][data-type="day"]');
if (yearSpinner && monthSpinner && daySpinner) {
log('Step 5: Birthday fields detected, filling birthday...');
async function setSpinButton(el, value) {
el.focus();
await sleep(100);
document.execCommand('selectAll', false, null);
await sleep(50);
const valueStr = String(value);
for (const char of valueStr) {
el.dispatchEvent(new KeyboardEvent('keydown', { key: char, code: `Digit${char}`, bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keypress', { key: char, code: `Digit${char}`, bubbles: true }));
el.dispatchEvent(new InputEvent('beforeinput', { inputType: 'insertText', data: char, bubbles: true }));
el.dispatchEvent(new InputEvent('input', { inputType: 'insertText', data: char, bubbles: true }));
await sleep(50);
}
el.dispatchEvent(new KeyboardEvent('keyup', { key: 'Tab', code: 'Tab', bubbles: true }));
el.blur();
await sleep(100);
}
await humanPause(450, 1100);
await setSpinButton(yearSpinner, year);
await humanPause(250, 650);
await setSpinButton(monthSpinner, String(month).padStart(2, '0'));
await humanPause(250, 650);
await setSpinButton(daySpinner, String(day).padStart(2, '0'));
log(`Step 5: Birthday filled: ${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`);
}
const hiddenBirthday = document.querySelector('input[name="birthday"]');
if (hiddenBirthday) {
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
hiddenBirthday.value = dateStr;
hiddenBirthday.dispatchEvent(new Event('change', { bubbles: true }));
log(`Step 5: Hidden birthday input set: ${dateStr}`);
}
} else if (ageInput) {
if (resolvedAge == null || Number.isNaN(Number(resolvedAge))) {
throw new Error('Age field detected, but no age data provided.');
}
await humanPause(500, 1300);
fillInput(ageInput, String(resolvedAge));
log(`Step 5: Age filled: ${resolvedAge}`);
// Some age-mode pages still submit a hidden birthday field.
// Keep it aligned with generated data so backend validation won't reject.
const hiddenBirthday = document.querySelector('input[name="birthday"]');
if (hiddenBirthday && hasBirthdayData) {
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
hiddenBirthday.value = dateStr;
hiddenBirthday.dispatchEvent(new Event('change', { bubbles: true }));
log(`Step 5: Hidden birthday input set (age mode): ${dateStr}`);
}
} else {
throw new Error('Could not find birthday or age input. URL: ' + location.href);
}
// Click "完成帐户创建" button
await sleep(500);
const completeBtn = document.querySelector('button[type="submit"]')
|| await waitForElementByText('button', /完成|create|continue|finish|done|agree/i, 5000).catch(() => null);
// Report complete BEFORE submit (page navigates to add-phone after this)
reportComplete(5);
if (completeBtn) {
await humanPause(500, 1300);
simulateClick(completeBtn);
log('Step 5: Clicked "完成帐户创建"');
}
}
@@ -0,0 +1,337 @@
// content/utils.js — Shared utilities for all content scripts
const SCRIPT_SOURCE = (() => {
if (window.__MULTIPAGE_SOURCE) return window.__MULTIPAGE_SOURCE;
const url = location.href;
if (url.includes('auth0.openai.com') || url.includes('auth.openai.com') || url.includes('accounts.openai.com')) return 'signup-page';
if (url.includes('mail.qq.com')) return 'qq-mail';
if (url.includes('mail.163.com')) return 'mail-163';
if (url.includes('duckduckgo.com/email/settings/autofill')) return 'duck-mail';
if (url.includes('relay.firefox.com/accounts/profile')) return 'relay-firefox';
if (url.includes('mail.cloudflare.com/admin')) return 'cloudflare-temp-email';
if (url.includes('chatgpt.com')) return 'chatgpt';
// VPS panel — detected dynamically since URL is configurable
return 'vps-panel';
})();
const LOG_PREFIX = `[MultiPage:${SCRIPT_SOURCE}]`;
const STOP_ERROR_MESSAGE = 'Flow stopped by user.';
let flowStopped = false;
chrome.runtime.onMessage.addListener((message) => {
if (message.type === 'STOP_FLOW') {
flowStopped = true;
console.warn(LOG_PREFIX, STOP_ERROR_MESSAGE);
}
});
function resetStopState() {
flowStopped = false;
}
function isStopError(error) {
const message = typeof error === 'string' ? error : error?.message;
return message === STOP_ERROR_MESSAGE;
}
function throwIfStopped() {
if (flowStopped) {
throw new Error(STOP_ERROR_MESSAGE);
}
}
/**
* Wait for a DOM element to appear.
* @param {string} selector - CSS selector
* @param {number} timeout - Max wait time in ms (default 10000)
* @returns {Promise<Element>}
*/
function waitForElement(selector, timeout = 10000) {
return new Promise((resolve, reject) => {
throwIfStopped();
const existing = document.querySelector(selector);
if (existing) {
console.log(LOG_PREFIX, `Found immediately: ${selector}`);
log(`Found element: ${selector}`);
resolve(existing);
return;
}
console.log(LOG_PREFIX, `Waiting for: ${selector} (timeout: ${timeout}ms)`);
log(`Waiting for selector: ${selector}...`);
let settled = false;
let stopTimer = null;
const cleanup = () => {
if (settled) return;
settled = true;
observer.disconnect();
clearTimeout(timer);
clearTimeout(stopTimer);
};
const observer = new MutationObserver(() => {
if (flowStopped) {
cleanup();
reject(new Error(STOP_ERROR_MESSAGE));
return;
}
const el = document.querySelector(selector);
if (el) {
cleanup();
console.log(LOG_PREFIX, `Found after wait: ${selector}`);
log(`Found element: ${selector}`);
resolve(el);
}
});
observer.observe(document.body || document.documentElement, {
childList: true,
subtree: true,
});
const timer = setTimeout(() => {
cleanup();
const msg = `Timeout waiting for ${selector} after ${timeout}ms on ${location.href}`;
console.error(LOG_PREFIX, msg);
reject(new Error(msg));
}, timeout);
const pollStop = () => {
if (settled) return;
if (flowStopped) {
cleanup();
reject(new Error(STOP_ERROR_MESSAGE));
return;
}
stopTimer = setTimeout(pollStop, 100);
};
pollStop();
});
}
/**
* Wait for an element matching a text pattern among multiple candidates.
* @param {string} containerSelector - Selector for candidate elements
* @param {RegExp} textPattern - Regex to match against textContent
* @param {number} timeout - Max wait time in ms
* @returns {Promise<Element>}
*/
function waitForElementByText(containerSelector, textPattern, timeout = 10000) {
return new Promise((resolve, reject) => {
throwIfStopped();
function search() {
const candidates = document.querySelectorAll(containerSelector);
for (const el of candidates) {
if (textPattern.test(el.textContent)) {
return el;
}
}
return null;
}
const existing = search();
if (existing) {
console.log(LOG_PREFIX, `Found by text immediately: ${containerSelector} matching ${textPattern}`);
log(`Found element by text: ${textPattern}`);
resolve(existing);
return;
}
console.log(LOG_PREFIX, `Waiting for text match: ${containerSelector} / ${textPattern}`);
log(`Waiting for element with text: ${textPattern}...`);
let settled = false;
let stopTimer = null;
const cleanup = () => {
if (settled) return;
settled = true;
observer.disconnect();
clearTimeout(timer);
clearTimeout(stopTimer);
};
const observer = new MutationObserver(() => {
if (flowStopped) {
cleanup();
reject(new Error(STOP_ERROR_MESSAGE));
return;
}
const el = search();
if (el) {
cleanup();
console.log(LOG_PREFIX, `Found by text after wait: ${textPattern}`);
log(`Found element by text: ${textPattern}`);
resolve(el);
}
});
observer.observe(document.body || document.documentElement, {
childList: true,
subtree: true,
});
const timer = setTimeout(() => {
cleanup();
const msg = `Timeout waiting for text "${textPattern}" in "${containerSelector}" after ${timeout}ms on ${location.href}`;
console.error(LOG_PREFIX, msg);
reject(new Error(msg));
}, timeout);
const pollStop = () => {
if (settled) return;
if (flowStopped) {
cleanup();
reject(new Error(STOP_ERROR_MESSAGE));
return;
}
stopTimer = setTimeout(pollStop, 100);
};
pollStop();
});
}
/**
* React-compatible form filling.
* Sets value via native setter and dispatches input + change events.
* @param {HTMLInputElement} el
* @param {string} value
*/
function fillInput(el, value) {
throwIfStopped();
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
'value'
).set;
nativeInputValueSetter.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
console.log(LOG_PREFIX, `Filled input ${el.name || el.id || el.type} with: ${value}`);
log(`Filled input [${el.name || el.id || el.type || 'unknown'}]`);
}
/**
* Fill a select element by setting its value and triggering change.
* @param {HTMLSelectElement} el
* @param {string} value
*/
function fillSelect(el, value) {
throwIfStopped();
el.value = value;
el.dispatchEvent(new Event('change', { bubbles: true }));
console.log(LOG_PREFIX, `Selected value ${value} in ${el.name || el.id}`);
log(`Selected [${el.name || el.id || 'unknown'}] = ${value}`);
}
/**
* Send a log message to Side Panel via Background.
* @param {string} message
* @param {string} level - 'info' | 'ok' | 'warn' | 'error'
*/
function log(message, level = 'info') {
chrome.runtime.sendMessage({
type: 'LOG',
source: SCRIPT_SOURCE,
step: null,
payload: { message, level, timestamp: Date.now() },
error: null,
});
}
/**
* Report that this content script is loaded and ready.
*/
function reportReady() {
console.log(LOG_PREFIX, 'Content script ready');
chrome.runtime.sendMessage({
type: 'CONTENT_SCRIPT_READY',
source: SCRIPT_SOURCE,
step: null,
payload: {},
error: null,
});
}
/**
* Report step completion.
* @param {number} step
* @param {Object} data - Step output data
*/
function reportComplete(step, data = {}) {
console.log(LOG_PREFIX, `Step ${step} completed`, data);
log(`Step ${step} completed successfully`, 'ok');
chrome.runtime.sendMessage({
type: 'STEP_COMPLETE',
source: SCRIPT_SOURCE,
step,
payload: data,
error: null,
});
}
/**
* Report step error.
* @param {number} step
* @param {string} errorMessage
*/
function reportError(step, errorMessage) {
console.error(LOG_PREFIX, `Step ${step} failed: ${errorMessage}`);
log(`Step ${step} failed: ${errorMessage}`, 'error');
chrome.runtime.sendMessage({
type: 'STEP_ERROR',
source: SCRIPT_SOURCE,
step,
payload: {},
error: errorMessage,
});
}
/**
* Simulate a click with proper event dispatching.
* @param {Element} el
*/
function simulateClick(el) {
throwIfStopped();
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
console.log(LOG_PREFIX, `Clicked: ${el.tagName} ${el.textContent?.slice(0, 30) || ''}`);
log(`Clicked [${el.tagName}] "${el.textContent?.trim().slice(0, 30) || ''}"`);
}
/**
* Wait a specified number of milliseconds.
* @param {number} ms
* @returns {Promise<void>}
*/
function sleep(ms) {
return new Promise((resolve, reject) => {
const start = Date.now();
function tick() {
if (flowStopped) {
reject(new Error(STOP_ERROR_MESSAGE));
return;
}
if (Date.now() - start >= ms) {
resolve();
return;
}
setTimeout(tick, Math.min(100, Math.max(25, ms - (Date.now() - start))));
}
tick();
});
}
async function humanPause(min = 250, max = 850) {
const duration = Math.floor(Math.random() * (max - min + 1)) + min;
await sleep(duration);
}
// Auto-report ready on load
// Skip ready signal from child iframes of mail pages to avoid overwriting the top frame's registration
const _isMailChildFrame = (SCRIPT_SOURCE === 'qq-mail' || SCRIPT_SOURCE === 'mail-163' || SCRIPT_SOURCE === 'inbucket-mail') && window !== window.top;
if (!_isMailChildFrame) {
reportReady();
}
@@ -0,0 +1,183 @@
// content/vps-panel.js — Content script for VPS panel (steps 1, 9)
// Injected on: VPS panel (user-configured URL)
//
// Actual DOM structure (after login click):
// <div class="card">
// <div class="card-header">
// <span class="OAuthPage-module__cardTitle___yFaP0">Codex OAuth</span>
// <button class="btn btn-primary"><span>登录</span></button>
// </div>
// <div class="OAuthPage-module__cardContent___1sXLA">
// <div class="OAuthPage-module__authUrlBox___Iu1d4">
// <div class="OAuthPage-module__authUrlLabel___mYFJB">授权链接:</div>
// <div class="OAuthPage-module__authUrlValue___axvUJ">https://auth.openai.com/...</div>
// <div class="OAuthPage-module__authUrlActions___venPj">
// <button class="btn btn-secondary btn-sm"><span>复制链接</span></button>
// <button class="btn btn-secondary btn-sm"><span>打开链接</span></button>
// </div>
// </div>
// <div class="OAuthPage-module__callbackSection___8kA31">
// <input class="input" placeholder="http://localhost:1455/auth/callback?code=...&state=...">
// <button class="btn btn-secondary btn-sm"><span>提交回调 URL</span></button>
// </div>
// </div>
// </div>
console.log('[MultiPage:vps-panel] Content script loaded on', location.href);
// Listen for commands from Background
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'EXECUTE_STEP') {
resetStopState();
handleStep(message.step, message.payload).then(() => {
sendResponse({ ok: true });
}).catch(err => {
if (isStopError(err)) {
log(`Step ${message.step}: Stopped by user.`, 'warn');
sendResponse({ stopped: true, error: err.message });
return;
}
reportError(message.step, err.message);
sendResponse({ error: err.message });
});
return true;
}
});
async function handleStep(step, payload) {
switch (step) {
case 1: return await step1_getOAuthLink();
case 9: return await step9_vpsVerify(payload);
default:
throw new Error(`vps-panel.js does not handle step ${step}`);
}
}
// ============================================================
// Step 1: Get OAuth Link
// ============================================================
async function step1_getOAuthLink() {
log('Step 1: Waiting for VPS panel to load (auto-login may take a moment)...');
// The page may start at #/login and auto-redirect to #/oauth.
// Wait for the Codex OAuth card to appear (up to 30s for auto-login + redirect).
let loginBtn = null;
try {
// Wait for any card-header containing "Codex" to appear
const header = await waitForElementByText('.card-header', /codex/i, 30000);
loginBtn = header.querySelector('button.btn.btn-primary, button.btn');
log('Step 1: Found Codex OAuth card');
} catch {
throw new Error(
'Codex OAuth card did not appear after 30s. Page may still be loading or not logged in. ' +
'Current URL: ' + location.href
);
}
if (!loginBtn) {
throw new Error('Found Codex OAuth card but no login button inside it. URL: ' + location.href);
}
// Check if button is disabled (already clicked / loading)
if (loginBtn.disabled) {
log('Step 1: Login button is disabled (already loading), waiting for auth URL...');
} else {
await humanPause(500, 1400);
simulateClick(loginBtn);
log('Step 1: Clicked login button, waiting for auth URL...');
}
// Wait for the auth URL to appear in the specific div
let authUrlEl = null;
try {
authUrlEl = await waitForElement('[class*="authUrlValue"]', 15000);
} catch {
throw new Error(
'Auth URL did not appear after clicking login. ' +
'Check if VPS panel is logged in and Codex service is running. URL: ' + location.href
);
}
const oauthUrl = (authUrlEl.textContent || '').trim();
if (!oauthUrl || !oauthUrl.startsWith('http')) {
throw new Error(`Invalid OAuth URL found: "${oauthUrl.slice(0, 50)}". Expected URL starting with http.`);
}
log(`Step 1: OAuth URL obtained: ${oauthUrl.slice(0, 80)}...`, 'ok');
reportComplete(1, { oauthUrl });
}
// ============================================================
// Step 9: VPS Verify — paste localhost URL and submit
// ============================================================
async function step9_vpsVerify(payload) {
// Get localhostUrl from payload (passed directly by background) or fallback to state
let localhostUrl = payload?.localhostUrl;
if (!localhostUrl) {
log('Step 9: localhostUrl not in payload, fetching from state...');
const state = await chrome.runtime.sendMessage({ type: 'GET_STATE' });
localhostUrl = state.localhostUrl;
}
if (!localhostUrl) {
throw new Error('No localhost URL found. Complete step 8 first.');
}
log(`Step 9: Got localhostUrl: ${localhostUrl.slice(0, 60)}...`);
log('Step 9: Looking for callback URL input...');
// Find the callback URL input
// Actual DOM: <input class="input" placeholder="http://localhost:1455/auth/callback?code=...&state=...">
let urlInput = null;
try {
urlInput = await waitForElement('[class*="callbackSection"] input.input', 10000);
} catch {
try {
urlInput = await waitForElement('input[placeholder*="localhost"]', 5000);
} catch {
throw new Error('Could not find callback URL input on VPS panel. URL: ' + location.href);
}
}
await humanPause(600, 1500);
fillInput(urlInput, localhostUrl);
log(`Step 9: Filled callback URL: ${localhostUrl.slice(0, 80)}...`);
// Find and click "提交回调 URL" button
let submitBtn = null;
try {
submitBtn = await waitForElementByText(
'[class*="callbackActions"] button, [class*="callbackSection"] button',
/提交/,
5000
);
} catch {
try {
submitBtn = await waitForElementByText('button.btn', /提交回调/, 5000);
} catch {
throw new Error('Could not find "提交回调 URL" button. URL: ' + location.href);
}
}
await humanPause(450, 1200);
simulateClick(submitBtn);
log('Step 9: Clicked "提交回调 URL", waiting for authentication result...');
// Wait for "认证成功!" status badge to appear
try {
await waitForElementByText('.status-badge, [class*="status"]', /认证成功/, 30000);
log('Step 9: Authentication successful!', 'ok');
} catch {
// Check if there's an error message instead
const statusEl = document.querySelector('.status-badge, [class*="status"]');
const statusText = statusEl ? statusEl.textContent : 'unknown';
if (/成功|success/i.test(statusText)) {
log('Step 9: Authentication successful!', 'ok');
} else {
log(`Step 9: Status after submit: "${statusText}". May still be processing.`, 'warn');
}
}
reportComplete(9);
}
@@ -0,0 +1,38 @@
// data/names.js — English name lists for random generation
const FIRST_NAMES = [
'James', 'John', 'Robert', 'Michael', 'William', 'David', 'Richard', 'Joseph', 'Thomas', 'Christopher',
'Mary', 'Patricia', 'Jennifer', 'Linda', 'Barbara', 'Elizabeth', 'Susan', 'Jessica', 'Sarah', 'Karen',
'Daniel', 'Matthew', 'Anthony', 'Mark', 'Donald', 'Steven', 'Andrew', 'Paul', 'Joshua', 'Kenneth',
'Emma', 'Olivia', 'Ava', 'Isabella', 'Sophia', 'Mia', 'Charlotte', 'Amelia', 'Harper', 'Evelyn',
];
const LAST_NAMES = [
'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez',
'Hernandez', 'Lopez', 'Gonzalez', 'Wilson', 'Anderson', 'Thomas', 'Taylor', 'Moore', 'Jackson', 'Martin',
'Lee', 'Perez', 'Thompson', 'White', 'Harris', 'Sanchez', 'Clark', 'Ramirez', 'Lewis', 'Robinson',
];
/**
* Generate a random full name.
* @returns {{ firstName: string, lastName: string }}
*/
function generateRandomName() {
const firstName = FIRST_NAMES[Math.floor(Math.random() * FIRST_NAMES.length)];
const lastName = LAST_NAMES[Math.floor(Math.random() * LAST_NAMES.length)];
return { firstName, lastName };
}
/**
* Generate a random birthday (age 19-25).
* @returns {{ year: number, month: number, day: number }}
*/
function generateRandomBirthday() {
const currentYear = new Date().getFullYear();
const age = 19 + Math.floor(Math.random() * 7); // 19 to 25
const year = currentYear - age;
const month = 1 + Math.floor(Math.random() * 12); // 1 to 12
const maxDay = new Date(year, month, 0).getDate(); // days in that month
const day = 1 + Math.floor(Math.random() * maxDay);
return { year, month, day };
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,78 @@
{
"manifest_version": 3,
"name": "Multi-Page Automation",
"version": "1.1.0",
"description": "Automates multi-step OAuth registration workflow",
"permissions": [
"sidePanel",
"tabs",
"webNavigation",
"debugger",
"storage",
"scripting",
"activeTab"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "background.js"
},
"side_panel": {
"default_path": "sidepanel/sidepanel.html"
},
"content_scripts": [
{
"matches": [
"https://auth0.openai.com/*",
"https://auth.openai.com/*",
"https://accounts.openai.com/*"
],
"js": ["content/utils.js", "shared/oauth-flow.js", "content/signup-page.js"],
"run_at": "document_idle"
},
{
"matches": [
"https://mail.qq.com/*",
"https://wx.mail.qq.com/*"
],
"js": ["content/utils.js", "shared/qq-mail.js", "content/qq-mail.js"],
"all_frames": true,
"run_at": "document_idle"
},
{
"matches": [
"https://mail.163.com/*"
],
"js": ["content/utils.js", "content/mail-163.js"],
"all_frames": true,
"run_at": "document_idle"
},
{
"matches": [
"https://duckduckgo.com/email/settings/autofill*"
],
"js": ["content/utils.js", "content/duck-mail.js"],
"run_at": "document_idle"
},
{
"matches": [
"https://relay.firefox.com/accounts/profile/*"
],
"js": ["content/utils.js", "shared/email-provider.js", "content/relay-firefox.js"],
"run_at": "document_idle"
}
],
"action": {
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
@@ -0,0 +1,389 @@
(function attachCloudflareTempEmailHelpers(globalScope) {
const PRIMARY_LOCAL_PART_WORD_BANKS = [
[
'anew', 'brisk', 'candid', 'fervent', 'gentle',
'humble', 'jovial', 'kindly', 'lucid', 'mellow',
'nimble', 'open', 'polished', 'quick', 'rosy',
'steady', 'tidy', 'upbeat', 'vantage', 'thunderous',
],
[
'angled', 'bordered', 'crisp', 'eager', 'frozen',
'golden', 'dotted', 'hushed', 'jagged', 'layered',
'mellowed', 'narrow', 'opal', 'primal', 'quiet',
'rippled', 'sunlit', 'trimmed', 'velvet', 'thumping',
],
[
'anchor', 'beacon', 'cinder', 'drifter', 'ember',
'meadow', 'nickel', 'orbit', 'prairie', 'quartz',
'rivet', 'latch', 'signal', 'thicket', 'uplift',
'voyager', 'willow', 'yonder', 'zephyr', 'wilderness',
],
];
const EXTENDED_LOCAL_PART_WORD_BANKS = [
['aurora', 'breezy', 'copper', 'drizzle', 'whimsy', 'glimmer', 'sapphire', 'harbor', 'inkwell', 'juniper'],
['almond', 'bronzed', 'cobbled', 'dappled', 'marbled', 'northern', 'moonlit', 'orchard', 'plaited', 'radiant'],
['acorn', 'bramble', 'citadel', 'daybreak', 'solstice', 'harvest', 'treeline', 'updraft', 'wildfire', 'yearling'],
];
const MID_EXTENDED_LOCAL_PART_WORD_BANKS = [
[
'citrine', 'dapper', 'elmwood', 'feather', 'gossamer',
'halcyon', 'ivory', 'kestrel', 'lively', 'mistral',
],
[
'lantern', 'mosaic', 'notched', 'oaken', 'pearled',
'quilted', 'rusted', 'silken', 'tapered', 'umber',
],
[
'meridian', 'northstar', 'overlook', 'peninsula', 'quickstep',
'ridgeline', 'starling', 'turnpike', 'undertow', 'vale',
],
];
const TOP_EXTENDED_LOCAL_PART_WORD_BANKS = [
['whimsy', 'afterglow', 'birdsong', 'clearwater', 'dreamscape', 'everbright', 'firecrest', 'hinterland', 'isleward', 'keystone'],
['marbled', 'auric', 'blossomed', 'celestial', 'dawnlit', 'embered', 'frosted', 'gilded', 'heartland', 'ironbound'],
['solstice', 'airstream', 'brightside', 'crestfall', 'dovetail', 'elmshade', 'fieldstone', 'goldleaf', 'highwater', 'ivytrail'],
];
const HIGH_EXTENDED_LOCAL_PART_WORD_BANKS = [
[
'adrift', 'bellwether', 'cedar', 'daystar', 'emberglow',
'fjord', 'glasswing', 'horizon', 'islander', 'jetstream',
'kingsley', 'longview', 'moonrise', 'northbound', 'oakleaf',
'pinelight', 'quasar', 'runestone', 'seaborne', 'trailhead',
],
[
'bronzed', 'cobbled', 'drifted', 'etched', 'fernlike',
'granulated', 'honeyed', 'indigo', 'jadeite', 'kindled',
'lacquered', 'measured', 'navy', 'opaline', 'painted',
'quenched', 'reeded', 'sanded', 'tempered', 'uplifted',
],
[
'cosmos', 'drumbeat', 'everglade', 'fjordline', 'grove',
'headland', 'icefield', 'journey', 'knoll', 'lagoon',
'moorland', 'narrows', 'outpost', 'passage', 'quarry',
'riverbend', 'shoal', 'tideline', 'upland', 'vista',
],
];
const APEX_EXTENDED_LOCAL_PART_WORD_BANKS = [
[
'atlas', 'bluebird', 'crestline', 'dewdrop', 'eastwind',
'flare', 'glen', 'harvestmoon', 'iris', 'joyride',
'kindred', 'larkspur', 'midway', 'nightfall', 'overture',
'prairiesky', 'quill', 'rosewood', 'sunflare', 'turnstone',
'uplight', 'violet', 'wildwood', 'xylia', 'yearbright',
'zenway', 'amberline', 'brightshore', 'cloudrest', 'dawnsong',
],
[
'bronze', 'coppered', 'dawnwashed', 'everspun', 'firelit',
'glazed', 'harbored', 'ivied', 'jade', 'keelmarked',
'leafed', 'misted', 'nacre', 'oakmoss', 'pearlstone',
'quartzite', 'rainsoft', 'sunwashed', 'timbered', 'umbered',
'velour', 'windcut', 'xanthic', 'yellowed', 'zestful',
'ashen', 'brightened', 'coasted', 'deepwater', 'emberlit',
],
[
'cosmos', 'daybreak', 'evercrest', 'fieldpath', 'groveside',
'hilltop', 'inlet', 'junction', 'keyway', 'lakeside',
'moonpath', 'nest', 'oakridge', 'portside', 'quayside',
'riverside', 'stonepath', 'trailway', 'uplook', 'valecrest',
'woodline', 'xylogrove', 'yardarm', 'zenithal', 'aircrest',
'bayshore', 'crossing', 'driftway', 'elmtrail', 'foreside',
],
];
const BASE_EXTENDED_WORD_RANGE_START = 0.91;
const MID_EXTENDED_WORD_RANGE_START = 0.95;
const TOP_EXTENDED_WORD_RANGE_START = 0.97;
const HIGH_EXTENDED_WORD_RANGE_START = 0.985;
const APEX_EXTENDED_WORD_RANGE_START = 0.993;
function normalizeWhitespace(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function combineDistinctTextParts(parts = []) {
const seen = new Set();
const normalizedParts = [];
for (const part of parts) {
const value = normalizeWhitespace(part);
if (!value || seen.has(value)) {
continue;
}
seen.add(value);
normalizedParts.push(value);
}
return normalizedParts.join(' ');
}
function normalizeText(value) {
return normalizeWhitespace(value).toLowerCase();
}
function normalizeEmail(value) {
return normalizeText(value);
}
function normalizeDomainSuffix(value) {
const match = normalizeText(value).match(/@?([a-z0-9.-]+\.[a-z]{2,})/i);
return match ? match[1].toLowerCase() : '';
}
function toFiniteNumber(value) {
const numeric = typeof value === 'number' ? value : Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function parseAdminTimestamp(value) {
const match = normalizeWhitespace(value).match(
/^(\d{4})\/(\d{1,2})\/(\d{1,2})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/
);
if (!match) return null;
const [, year, month, day, hour, minute, second = '0'] = match;
const timestamp = new Date(
Number(year),
Number(month) - 1,
Number(day),
Number(hour),
Number(minute),
Number(second)
).getTime();
return Number.isFinite(timestamp) ? timestamp : null;
}
function extractVerificationCode(text) {
const content = String(text || '');
const matchCn = content.match(/(?:代码为|验证码[^0-9]*?)[\s:]*(\d{6})/);
if (matchCn) return matchCn[1];
const matchEn = content.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
if (matchEn) return matchEn[1] || matchEn[2];
const match6 = content.match(/\b(\d{6})\b/);
if (match6) return match6[1];
return null;
}
function decodeBase64Url(segment) {
const base64 = String(segment || '')
.replace(/-/g, '+')
.replace(/_/g, '/');
const padding = base64.length % 4 === 0 ? '' : '='.repeat(4 - (base64.length % 4));
const padded = base64 + padding;
if (typeof Buffer !== 'undefined') {
return Buffer.from(padded, 'base64').toString('utf8');
}
if (typeof atob === 'function') {
return atob(padded);
}
throw new Error('No base64 decoder available.');
}
function decodeJwtPayload(token) {
const parts = String(token || '').split('.');
if (parts.length < 2) return null;
try {
return JSON.parse(decodeBase64Url(parts[1]));
} catch {
return null;
}
}
function parseCloudflareMailboxCredential(token) {
const jwtMatch = String(token || '').match(/[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/);
if (!jwtMatch) return null;
const payload = decodeJwtPayload(jwtMatch[0]);
const email = normalizeEmail(payload?.address || payload?.email || '');
if (!email || !email.includes('@')) {
return null;
}
const [localPart, ...domainParts] = email.split('@');
const domain = domainParts.join('@');
return {
addressId: toFiniteNumber(payload?.address_id),
domain,
email,
localPart,
provenance: 'created',
};
}
function pickWord(words, randomFn) {
const randomValue = Math.max(0, Math.min(0.999999999999, Number(randomFn())));
return words[Math.floor(randomValue * words.length)] || words[0];
}
function pickReadableWord(bankIndex, randomFn) {
const randomValue = Math.max(0, Math.min(0.999999999999, Number(randomFn())));
const primaryWords = PRIMARY_LOCAL_PART_WORD_BANKS[bankIndex] || [];
const extendedWords = EXTENDED_LOCAL_PART_WORD_BANKS[bankIndex] || [];
const midExtendedWords = MID_EXTENDED_LOCAL_PART_WORD_BANKS[bankIndex] || [];
const topExtendedWords = TOP_EXTENDED_LOCAL_PART_WORD_BANKS[bankIndex] || [];
const highExtendedWords = HIGH_EXTENDED_LOCAL_PART_WORD_BANKS[bankIndex] || [];
const apexExtendedWords = APEX_EXTENDED_LOCAL_PART_WORD_BANKS[bankIndex] || [];
if (apexExtendedWords.length > 0 && randomValue >= APEX_EXTENDED_WORD_RANGE_START) {
const apexExtendedSpan = 1 - APEX_EXTENDED_WORD_RANGE_START;
const apexExtendedValue = Math.min(0.999999999999, (randomValue - APEX_EXTENDED_WORD_RANGE_START) / apexExtendedSpan);
return pickWord(apexExtendedWords, () => apexExtendedValue);
}
if (highExtendedWords.length > 0 && randomValue >= HIGH_EXTENDED_WORD_RANGE_START) {
const highExtendedSpan = APEX_EXTENDED_WORD_RANGE_START - HIGH_EXTENDED_WORD_RANGE_START;
const highExtendedValue = Math.min(0.999999999999, (randomValue - HIGH_EXTENDED_WORD_RANGE_START) / highExtendedSpan);
return pickWord(highExtendedWords, () => highExtendedValue);
}
if (topExtendedWords.length > 0 && randomValue >= TOP_EXTENDED_WORD_RANGE_START) {
const topExtendedSpan = HIGH_EXTENDED_WORD_RANGE_START - TOP_EXTENDED_WORD_RANGE_START;
const topExtendedValue = Math.min(0.999999999999, (randomValue - TOP_EXTENDED_WORD_RANGE_START) / topExtendedSpan);
return pickWord(topExtendedWords, () => topExtendedValue);
}
if (midExtendedWords.length > 0 && randomValue >= MID_EXTENDED_WORD_RANGE_START) {
const midExtendedSpan = TOP_EXTENDED_WORD_RANGE_START - MID_EXTENDED_WORD_RANGE_START;
const midExtendedValue = Math.min(0.999999999999, (randomValue - MID_EXTENDED_WORD_RANGE_START) / midExtendedSpan);
return pickWord(midExtendedWords, () => midExtendedValue);
}
if (extendedWords.length > 0 && randomValue >= BASE_EXTENDED_WORD_RANGE_START) {
const extendedSpan = MID_EXTENDED_WORD_RANGE_START - BASE_EXTENDED_WORD_RANGE_START;
const extendedValue = Math.min(0.999999999999, (randomValue - BASE_EXTENDED_WORD_RANGE_START) / extendedSpan);
return pickWord(extendedWords, () => extendedValue);
}
return pickWord(primaryWords, () => randomValue);
}
function generateReadableLocalPart(randomFn = Math.random, maxLength = 24) {
for (let attempt = 1; attempt <= 20; attempt++) {
const value = PRIMARY_LOCAL_PART_WORD_BANKS
.map((_, bankIndex) => pickReadableWord(bankIndex, randomFn))
.join('-');
if (value.length <= maxLength) {
return value;
}
}
return 'anew-dotted-latch';
}
function pickRandomSuffix(options = [], randomFn = Math.random) {
const seen = new Set();
const suffixes = [];
for (const option of options) {
const suffix = normalizeDomainSuffix(option);
if (!suffix || seen.has(suffix)) {
continue;
}
seen.add(suffix);
suffixes.push(suffix);
}
if (suffixes.length === 0) {
return '';
}
return pickWord(suffixes, randomFn);
}
function compareMessageIds(left, right) {
const leftNumber = toFiniteNumber(left);
const rightNumber = toFiniteNumber(right);
if (leftNumber !== null && rightNumber !== null) {
return rightNumber - leftNumber;
}
return String(right || '').localeCompare(String(left || ''));
}
function selectVerificationMessage(messages = [], options = {}) {
const targetEmail = normalizeEmail(options.targetEmail || '');
const senderFilters = (options.senderFilters || []).map(normalizeText);
const subjectFilters = (options.subjectFilters || []).map(normalizeText);
const filterAfterTimestamp = toFiniteNumber(options.filterAfterTimestamp) || 0;
const candidates = [];
for (const message of messages) {
const matchedEmail = normalizeEmail(message?.matchedEmail || message?.toEmail || '');
if (targetEmail && matchedEmail && matchedEmail !== targetEmail) {
continue;
}
if (targetEmail && !matchedEmail) {
continue;
}
const subject = normalizeWhitespace(message?.subject || '');
const combinedText = normalizeWhitespace(message?.combinedText || '');
const sender = normalizeText(message?.sender || '');
const searchText = normalizeText(`${subject} ${combinedText}`);
const code = extractVerificationCode(`${subject} ${combinedText}`);
if (!code) {
continue;
}
const senderMatch = senderFilters.length === 0
|| senderFilters.some((filter) => sender.includes(filter) || searchText.includes(filter));
const subjectMatch = subjectFilters.length === 0
|| subjectFilters.some((filter) => normalizeText(subject).includes(filter) || searchText.includes(filter));
if (!senderMatch && !subjectMatch) {
continue;
}
const emailTimestamp = toFiniteNumber(message?.emailTimestamp)
|| parseAdminTimestamp(message?.timestampText || '');
if (filterAfterTimestamp > 0 && (!emailTimestamp || emailTimestamp <= filterAfterTimestamp)) {
continue;
}
candidates.push({
code,
emailTimestamp: emailTimestamp || 0,
matchedEmail,
messageId: message?.messageId ?? null,
subject: subject || null,
});
}
candidates.sort((left, right) => {
if (left.emailTimestamp !== right.emailTimestamp) {
return right.emailTimestamp - left.emailTimestamp;
}
return compareMessageIds(left.messageId, right.messageId);
});
return candidates[0] || null;
}
const api = {
combineDistinctTextParts,
extractVerificationCode,
generateReadableLocalPart,
normalizeDomainSuffix,
parseAdminTimestamp,
parseCloudflareMailboxCredential,
pickRandomSuffix,
selectVerificationMessage,
};
globalScope.MultiPageCloudflareTempEmail = api;
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
})(typeof globalThis !== 'undefined' ? globalThis : this);
@@ -0,0 +1,99 @@
(function attachEmailProviderHelpers(globalScope) {
const DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL = 'https://mail.cloudflare.com/admin';
const EMAIL_PROVIDER_DUCK = 'duckduckgo';
const EMAIL_PROVIDER_RELAY_FIREFOX = 'relay_firefox';
const EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL = 'cloudflare_temp_email';
function normalizeEmailProvider(value) {
if (value === EMAIL_PROVIDER_RELAY_FIREFOX) {
return EMAIL_PROVIDER_RELAY_FIREFOX;
}
if (value === EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL) {
return EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL;
}
return EMAIL_PROVIDER_DUCK;
}
function isRelayFirefoxProvider(value) {
return normalizeEmailProvider(value) === EMAIL_PROVIDER_RELAY_FIREFOX;
}
function isCloudflareTempEmailProvider(value) {
return normalizeEmailProvider(value) === EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL;
}
function getEmailProviderDisplayName(value) {
if (isRelayFirefoxProvider(value)) {
return 'Firefox Relay';
}
if (isCloudflareTempEmailProvider(value)) {
return 'Cloudflare Temp Email';
}
return 'DuckDuckGo';
}
function shouldUseEmailSourceForVerification(value) {
return isCloudflareTempEmailProvider(value);
}
function shouldSkipStep9Cleanup(value) {
return !isRelayFirefoxProvider(value);
}
function normalizeCloudflareTempEmailAdminUrl(value) {
const raw = String(value || '').trim();
if (!raw) {
return DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL;
}
const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
try {
const parsed = new URL(candidate);
parsed.pathname = parsed.pathname.replace(/\/+$/, '') || '/';
return parsed.toString();
} catch {
return DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL;
}
}
function getNextRelayMaskLabel(labels = []) {
const used = new Set();
for (const rawLabel of labels) {
const match = String(rawLabel || '').trim().match(/^t(\d+)$/i);
if (!match) continue;
const nextValue = Number(match[1]);
if (Number.isInteger(nextValue) && nextValue > 0) {
used.add(nextValue);
}
}
let candidate = 1;
while (used.has(candidate)) {
candidate += 1;
}
return `t${candidate}`;
}
const api = {
DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL,
EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL,
EMAIL_PROVIDER_DUCK,
EMAIL_PROVIDER_RELAY_FIREFOX,
getEmailProviderDisplayName,
getNextRelayMaskLabel,
isCloudflareTempEmailProvider,
isRelayFirefoxProvider,
normalizeCloudflareTempEmailAdminUrl,
normalizeEmailProvider,
shouldUseEmailSourceForVerification,
shouldSkipStep9Cleanup,
};
globalScope.MultiPageEmailProvider = api;
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
})(typeof globalThis !== 'undefined' ? globalThis : this);
@@ -0,0 +1,85 @@
(function attachOAuthFlowHelpers(globalScope) {
const EXACT_CONSENT_PATH = '/sign-in-with-chatgpt/codex/consent';
const SIGN_IN_WITH_CHATGPT_PATH_SEGMENT = '/sign-in-with-chatgpt/';
function parseUrl(input) {
if (!input || typeof input !== 'string') {
return null;
}
try {
return new URL(input);
} catch {
return null;
}
}
function isConsentUrl(url) {
const parsed = parseUrl(url);
if (!parsed) {
return false;
}
return parsed.pathname === EXACT_CONSENT_PATH;
}
function isConsentPageState(state = {}) {
const { hasVisibleContinueButton = false, url = '' } = state;
if (isConsentUrl(url)) {
return true;
}
const parsed = parseUrl(url);
if (!parsed) {
return false;
}
return parsed.pathname.includes(SIGN_IN_WITH_CHATGPT_PATH_SEGMENT) && Boolean(hasVisibleContinueButton);
}
function hasAnyConsentPageState(states = []) {
return states.some((state) => isConsentPageState(state));
}
function isLoopbackCallbackUrl(url) {
const parsed = parseUrl(url);
if (!parsed) {
return false;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false;
}
return parsed.hostname === 'localhost'
|| parsed.hostname === '127.0.0.1'
|| parsed.hostname === '::1'
|| parsed.hostname === '[::1]';
}
function findLoopbackCallbackUrl(candidates = []) {
for (const candidate of candidates) {
if (isLoopbackCallbackUrl(candidate)) {
return candidate;
}
}
return null;
}
const api = {
EXACT_CONSENT_PATH,
SIGN_IN_WITH_CHATGPT_PATH_SEGMENT,
findLoopbackCallbackUrl,
hasAnyConsentPageState,
isConsentPageState,
isConsentUrl,
isLoopbackCallbackUrl,
};
globalScope.MultiPageOAuthFlow = api;
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
})(typeof globalThis !== 'undefined' ? globalThis : this);
@@ -0,0 +1,67 @@
(function attachQQMailHelpers(globalScope) {
function normalizeText(value) {
return (value || '').toLowerCase();
}
function extractVerificationCode(text) {
const matchCn = text.match(/(?:代码为|验证码[^0-9]*?)[\s:]*(\d{6})/);
if (matchCn) return matchCn[1];
const matchEn = text.match(/code[:\s]+is[:\s]+(\d{6})|code[:\s]+(\d{6})/i);
if (matchEn) return matchEn[1] || matchEn[2];
const match6 = text.match(/\b(\d{6})\b/);
if (match6) return match6[1];
return null;
}
function findNewQQVerificationCode(messages = [], options = {}) {
const existingMailIds = new Set(options.existingMailIds || []);
const senderFilters = options.senderFilters || [];
const subjectFilters = options.subjectFilters || [];
for (const message of messages) {
const mailId = message.mailId || '';
if (!mailId || existingMailIds.has(mailId)) {
continue;
}
const sender = normalizeText(message.sender);
const subject = normalizeText(message.subject);
const digest = message.digest || '';
const senderMatch = senderFilters.some((filter) => sender.includes(normalizeText(filter)));
const subjectMatch = subjectFilters.some((filter) => subject.includes(normalizeText(filter)));
if (!senderMatch && !subjectMatch) {
continue;
}
const code = extractVerificationCode(`${message.subject || ''} ${digest}`);
if (!code) {
continue;
}
return {
code,
mailId,
source: 'new',
subject: message.subject || '',
};
}
return null;
}
const api = {
extractVerificationCode,
findNewQQVerificationCode,
};
globalScope.MultiPageQQMail = api;
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
})(typeof globalThis !== 'undefined' ? globalThis : this);
@@ -0,0 +1,669 @@
/* ============================================================
MultiPage Automation — Side Panel
Design: Swiss Modernism + Developer Tool
Font: Inter (UI) + JetBrains Mono (code)
Themes: Light (default) + Dark (toggle)
============================================================ */
/* ---- Light Theme (default) ---- */
:root {
--bg-base: #ffffff;
--bg-surface: #f7f8fa;
--bg-elevated: #eef0f4;
--bg-hover: #e4e7ec;
--bg-active: #dce0e8;
--border: #d8dce3;
--border-subtle: #e8ecf1;
--text-primary: #1a1d24;
--text-secondary: #5c6370;
--text-muted: #9ca3af;
--blue: #2563eb;
--blue-soft: rgba(37, 99, 235, 0.08);
--blue-glow: rgba(37, 99, 235, 0.12);
--green: #16a34a;
--green-soft: rgba(22, 163, 74, 0.08);
--orange: #ea580c;
--orange-soft: rgba(234, 88, 12, 0.08);
--red: #dc2626;
--red-soft: rgba(220, 38, 38, 0.08);
--cyan: #0891b2;
--purple: #7c3aed;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 2px 6px rgba(0,0,0,0.06);
--radius-sm: 6px;
--radius-md: 8px;
--transition: 150ms ease;
}
/* ---- Dark Theme ---- */
[data-theme="dark"] {
--bg-base: #0f1117;
--bg-surface: #181a21;
--bg-elevated: #21242d;
--bg-hover: #2a2e38;
--bg-active: #323844;
--border: #2a2e38;
--border-subtle: #21242d;
--text-primary: #e4e6eb;
--text-secondary: #8b919e;
--text-muted: #565c6a;
--blue: #3b82f6;
--blue-soft: rgba(59, 130, 246, 0.12);
--blue-glow: rgba(59, 130, 246, 0.18);
--green: #22c55e;
--green-soft: rgba(34, 197, 94, 0.12);
--orange: #f97316;
--orange-soft: rgba(249, 115, 22, 0.12);
--red: #ef4444;
--red-soft: rgba(239, 68, 68, 0.12);
--cyan: #06b6d4;
--purple: #a78bfa;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.2);
--shadow-md: 0 2px 8px rgba(0,0,0,0.3);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 14px;
color: var(--text-primary);
background: var(--bg-base);
padding: 12px;
width: 100%;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
transition: background var(--transition), color var(--transition);
}
/* ============================================================
Header
============================================================ */
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-subtle);
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.header-left svg { color: var(--blue); }
.header-left h1 {
font-size: 16px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text-primary);
}
.header-btns {
display: flex;
align-items: center;
gap: 4px;
}
/* ============================================================
Theme Toggle
============================================================ */
.theme-toggle {
width: 30px;
height: 30px;
border: none;
background: transparent;
color: var(--text-muted);
border-radius: var(--radius-sm);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition);
}
.theme-toggle:hover { background: var(--bg-hover); color: var(--text-primary); }
.theme-toggle .icon-moon { display: block; }
.theme-toggle .icon-sun { display: none; }
[data-theme="dark"] .theme-toggle .icon-moon { display: none; }
[data-theme="dark"] .theme-toggle .icon-sun { display: block; }
/* ============================================================
Buttons
============================================================ */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
font-family: inherit;
font-size: 13px;
font-weight: 600;
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition);
white-space: nowrap;
}
.btn-primary {
background: var(--blue);
color: #fff;
}
.btn-primary:hover { opacity: 0.9; box-shadow: 0 2px 8px var(--blue-glow); }
.btn-success {
background: var(--green);
color: #fff;
}
.btn-success:hover { opacity: 0.9; box-shadow: 0 2px 8px var(--green-soft); }
.btn-danger {
background: var(--red);
color: #fff;
}
.btn-danger:hover { opacity: 0.9; box-shadow: 0 2px 8px var(--red-soft); }
.run-group {
display: flex;
align-items: center;
gap: 4px;
}
.run-count-input {
width: 42px;
padding: 6px 4px;
text-align: center;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
font-weight: 600;
outline: none;
}
.run-count-input:focus { border-color: var(--blue); }
.run-count-input::-webkit-inner-spin-button { opacity: 0.5; }
.btn-success:disabled, .btn-primary:disabled, .btn-danger:disabled { background: var(--bg-elevated); color: var(--text-muted); cursor: not-allowed; box-shadow: none; }
.run-count-input:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-ghost {
background: transparent;
color: var(--text-secondary);
padding: 6px;
border-radius: var(--radius-sm);
}
.btn-ghost:hover { background: var(--bg-hover); color: var(--text-primary); }
.btn-outline {
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
}
.btn-outline:hover { border-color: var(--blue); color: var(--blue); background: var(--blue-soft); }
.btn-sm { padding: 5px 12px; font-size: 12px; }
.btn-xs { padding: 4px 10px; font-size: 11px; }
/* ============================================================
Data Card
============================================================ */
#data-section { margin-bottom: 14px; }
.data-card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 9px;
box-shadow: var(--shadow-sm);
}
.data-row {
display: flex;
align-items: center;
gap: 8px;
}
.data-inline {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.data-label {
width: 56px;
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
flex-shrink: 0;
}
.data-value {
font-size: 13px;
color: var(--text-muted);
min-width: 0;
}
.data-value.has-value { color: var(--text-primary); }
.mono {
font-family: 'JetBrains Mono', 'Consolas', monospace;
font-size: 12px;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.data-input {
flex: 1;
padding: 7px 10px;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
outline: none;
transition: border-color var(--transition), box-shadow var(--transition);
min-width: 0;
}
.data-input::placeholder { color: var(--text-muted); }
.data-input:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
#btn-fetch-email {
padding-inline: 10px;
}
#btn-toggle-password {
min-width: 58px;
padding-inline: 10px;
}
.data-select {
flex: 1;
padding: 7px 10px;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: inherit;
font-size: 13px;
outline: none;
cursor: pointer;
transition: border-color var(--transition);
min-width: 0;
}
.data-select:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
[data-theme="dark"] .data-select { color-scheme: dark; }
/* Status Bar */
.status-bar {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
padding: 8px 12px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
box-shadow: var(--shadow-sm);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted);
flex-shrink: 0;
transition: background var(--transition);
}
.status-bar.running .status-dot {
background: var(--orange);
animation: pulse 1.5s ease-in-out infinite;
}
.status-bar.running { color: var(--orange); }
.status-bar.completed .status-dot { background: var(--green); }
.status-bar.completed { color: var(--green); }
.status-bar.failed .status-dot { background: var(--red); }
.status-bar.failed { color: var(--red); }
.status-bar.stopped .status-dot { background: var(--cyan); }
.status-bar.stopped { color: var(--cyan); }
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.85); }
}
/* Auto Continue Bar */
.auto-continue-bar {
margin-top: 8px;
padding: 8px 10px;
background: var(--orange-soft);
border: 1px solid rgba(234, 88, 12, 0.2);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
gap: 8px;
}
.auto-continue-bar svg { color: var(--orange); flex-shrink: 0; }
.auto-hint { font-size: 13px; color: var(--orange); flex: 1; font-weight: 500; }
/* ============================================================
Steps Section
============================================================ */
#steps-section { margin-bottom: 14px; }
.steps-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.section-label {
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.steps-progress {
font-family: 'JetBrains Mono', monospace;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
background: var(--bg-surface);
padding: 2px 8px;
border-radius: 10px;
border: 1px solid var(--border);
}
.steps-list {
display: flex;
flex-direction: column;
gap: 5px;
}
.step-row {
display: flex;
align-items: center;
gap: 8px;
padding: 1px 0;
transition: opacity var(--transition);
}
/* Step Number Indicator */
.step-indicator {
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--bg-surface);
border: 1.5px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all var(--transition);
}
.step-num {
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
transition: color var(--transition);
}
.step-row.running .step-indicator { border-color: var(--orange); background: var(--orange-soft); }
.step-row.running .step-num { color: var(--orange); }
.step-row.running .step-indicator { animation: pulse 1.5s ease-in-out infinite; }
.step-row.completed .step-indicator { border-color: var(--green); background: var(--green-soft); }
.step-row.completed .step-num { color: var(--green); }
.step-row.failed .step-indicator { border-color: var(--red); background: var(--red-soft); }
.step-row.failed .step-num { color: var(--red); }
.step-row.stopped .step-indicator { border-color: var(--cyan); background: rgba(8, 145, 178, 0.08); }
.step-row.stopped .step-num { color: var(--cyan); }
/* Step Button */
.step-btn {
flex: 1;
padding: 8px 12px;
font-family: inherit;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
text-align: left;
transition: all var(--transition);
box-shadow: var(--shadow-sm);
}
.step-btn:hover:not(:disabled) { background: var(--bg-hover); border-color: var(--blue); }
.step-btn:disabled { color: var(--text-muted); background: var(--bg-base); border-color: var(--border-subtle); cursor: not-allowed; opacity: 0.45; box-shadow: none; }
.step-row.running .step-btn { border-color: var(--orange); color: var(--orange); }
.step-row.completed .step-btn { border-color: var(--border-subtle); color: var(--text-secondary); opacity: 0.7; }
.step-row.failed .step-btn { border-color: var(--red); color: var(--red); }
.step-row.stopped .step-btn { border-color: var(--cyan); color: var(--cyan); }
.step-status {
width: 20px;
text-align: center;
font-size: 14px;
font-weight: 700;
flex-shrink: 0;
}
.step-row.completed .step-status { color: var(--green); }
.step-row.failed .step-status { color: var(--red); }
.step-row.stopped .step-status { color: var(--cyan); }
/* ============================================================
Log / Console Section
============================================================ */
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
#log-area {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 10px 12px;
height: 220px;
overflow-y: auto;
font-family: 'JetBrains Mono', 'Consolas', monospace;
font-size: 12px;
line-height: 1.7;
color: var(--text-secondary);
box-shadow: var(--shadow-sm);
}
#log-area::-webkit-scrollbar { width: 5px; }
#log-area::-webkit-scrollbar-track { background: transparent; }
#log-area::-webkit-scrollbar-thumb { background: var(--bg-elevated); border-radius: 4px; }
#log-area::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
.log-line { padding: 2.5px 0; }
.log-line + .log-line { border-top: 1px solid var(--border-subtle); }
.log-time { color: var(--text-muted); }
.log-level { font-weight: 700; margin: 0 2px; }
.log-level-info { color: var(--blue); }
.log-level-ok { color: var(--green); }
.log-level-warn { color: var(--orange); }
.log-level-error { color: var(--red); }
.log-step-tag {
display: inline-block;
padding: 1px 5px;
border-radius: 3px;
font-weight: 700;
font-size: 11px;
margin-right: 3px;
}
.log-step-tag.step-1 { color: var(--cyan); background: rgba(8, 145, 178, 0.08); }
.log-step-tag.step-2 { color: var(--purple); background: rgba(124, 58, 237, 0.08); }
.log-step-tag.step-3 { color: #b45309; background: rgba(180, 83, 9, 0.06); }
[data-theme="dark"] .log-step-tag.step-3 { color: #fbbf24; background: rgba(251, 191, 36, 0.1); }
.log-step-tag.step-4 { color: var(--orange); background: var(--orange-soft); }
.log-step-tag.step-5 { color: var(--green); background: var(--green-soft); }
.log-step-tag.step-6 { color: var(--cyan); background: rgba(8, 145, 178, 0.08); }
.log-step-tag.step-7 { color: var(--orange); background: var(--orange-soft); }
.log-step-tag.step-8 { color: var(--purple); background: rgba(124, 58, 237, 0.08); }
.log-step-tag.step-9 { color: var(--green); background: var(--green-soft); }
.log-msg { color: var(--text-secondary); }
.log-line.log-ok .log-msg { color: var(--green); font-weight: 500; }
.log-line.log-error .log-msg { color: var(--red); font-weight: 500; }
.log-line.log-warn .log-msg { color: var(--orange); }
/* ============================================================
Animations
============================================================ */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(3px); }
to { opacity: 1; transform: translateY(0); }
}
.log-line { animation: fadeIn 120ms ease-out; }
/* ============================================================
Toast Notifications
============================================================ */
#toast-container {
position: fixed;
top: 12px;
left: 12px;
right: 12px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 6px;
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: var(--radius-md);
font-size: 13px;
font-weight: 500;
box-shadow: var(--shadow-md), 0 4px 12px rgba(0,0,0,0.1);
pointer-events: auto;
animation: toastIn 250ms ease-out;
border: 1px solid;
}
.toast.toast-exit {
animation: toastOut 200ms ease-in forwards;
}
.toast-error {
background: var(--red-soft);
border-color: var(--red);
color: var(--red);
}
.toast-warn {
background: var(--orange-soft);
border-color: var(--orange);
color: var(--orange);
}
.toast-success {
background: var(--green-soft);
border-color: var(--green);
color: var(--green);
}
.toast-info {
background: var(--blue-soft);
border-color: var(--blue);
color: var(--blue);
}
.toast svg {
flex-shrink: 0;
}
.toast-msg {
flex: 1;
}
.toast-close {
background: none;
border: none;
color: inherit;
opacity: 0.6;
cursor: pointer;
padding: 2px;
font-size: 16px;
line-height: 1;
}
.toast-close:hover { opacity: 1; }
@keyframes toastIn {
from { opacity: 0; transform: translateY(-10px) scale(0.96); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes toastOut {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(-10px) scale(0.96); }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multi-Page Automation</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="sidepanel.css">
</head>
<body>
<header>
<div class="header-left">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>
</svg>
<h1>MultiPage</h1>
</div>
<div class="header-btns">
<div class="run-group">
<input type="number" id="input-run-count" class="run-count-input" value="1" min="1" max="50" title="Number of runs" />
<button id="btn-auto-run" class="btn btn-success" title="Run all steps automatically">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Auto
</button>
<button id="btn-stop" class="btn btn-danger" title="Stop current flow" disabled>Stop</button>
</div>
<button id="btn-reset" class="btn btn-ghost" title="Reset all steps">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
</svg>
</button>
<button id="btn-theme" class="theme-toggle" title="Toggle theme">
<svg class="icon-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
<svg class="icon-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
</button>
</div>
</header>
<section id="data-section">
<div class="data-card">
<div class="data-row">
<span class="data-label">VPS</span>
<input type="password" id="input-vps-url" class="data-input" placeholder="http://ip:port/management.html#/oauth" />
</div>
<div class="data-row">
<span class="data-label">Mail</span>
<select id="select-mail-provider" class="data-select">
<option value="163">163 Mail (mail.163.com)</option>
<option value="qq">QQ Mail (wx.mail.qq.com)</option>
<option value="inbucket">Inbucket (custom host)</option>
</select>
</div>
<div class="data-row">
<span class="data-label">Source</span>
<select id="select-email-provider" class="data-select">
<option value="duckduckgo">duckduckgo</option>
<option value="cloudflare_temp_email">cloudflare_temp_email</option>
<option value="relay_firefox">relay_firefox</option>
</select>
</div>
<div class="data-row" id="row-cloudflare-temp-email-url" style="display:none;">
<span class="data-label">Cloudflare</span>
<input type="text" id="input-cloudflare-temp-email-url" class="data-input" placeholder="https://mail.cloudflare.com/admin" />
</div>
<div class="data-row" id="row-inbucket-host" style="display:none;">
<span class="data-label">Inbucket</span>
<input type="text" id="input-inbucket-host" class="data-input" placeholder="your-inbucket-host or https://your-inbucket-host" />
</div>
<div class="data-row" id="row-inbucket-mailbox" style="display:none;">
<span class="data-label">Mailbox</span>
<input type="text" id="input-inbucket-mailbox" class="data-input" placeholder="e.g. zju2001" />
</div>
<div class="data-row">
<span class="data-label">Email</span>
<div class="data-inline">
<input type="text" id="input-email" class="data-input" placeholder="Paste signup email" />
<button id="btn-fetch-email" class="btn btn-outline btn-sm" type="button">Auto</button>
</div>
</div>
<div class="data-row">
<span class="data-label">Password</span>
<div class="data-inline">
<input type="password" id="input-password" class="data-input" placeholder="Leave blank to auto-generate" />
<button id="btn-toggle-password" class="btn btn-outline btn-sm" type="button">Show</button>
</div>
</div>
<div class="data-row">
<span class="data-label">OAuth</span>
<span id="display-oauth-url" class="data-value mono truncate">Waiting...</span>
</div>
<div class="data-row">
<span class="data-label">Callback</span>
<span id="display-localhost-url" class="data-value mono truncate">Waiting...</span>
</div>
</div>
<div id="status-bar" class="status-bar">
<div class="status-dot"></div>
<span id="display-status">Ready</span>
</div>
<div id="auto-continue-bar" class="auto-continue-bar" style="display:none;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<span id="auto-hint" class="auto-hint">Use Auto to fetch email, or paste manually, then continue</span>
<button id="btn-auto-continue" class="btn btn-primary btn-sm">Continue</button>
</div>
</section>
<section id="steps-section">
<div class="steps-header">
<span class="section-label">Workflow</span>
<span id="steps-progress" class="steps-progress">0 / 9</span>
</div>
<div class="steps-list">
<div class="step-row" data-step="1">
<div class="step-indicator" data-step="1"><span class="step-num">1</span></div>
<button class="step-btn" data-step="1">Get OAuth Link</button>
<span class="step-status" data-step="1"></span>
</div>
<div class="step-row" data-step="2">
<div class="step-indicator" data-step="2"><span class="step-num">2</span></div>
<button class="step-btn" data-step="2">Open Signup</button>
<span class="step-status" data-step="2"></span>
</div>
<div class="step-row" data-step="3">
<div class="step-indicator" data-step="3"><span class="step-num">3</span></div>
<button class="step-btn" data-step="3">Fill Email / Password</button>
<span class="step-status" data-step="3"></span>
</div>
<div class="step-row" data-step="4">
<div class="step-indicator" data-step="4"><span class="step-num">4</span></div>
<button class="step-btn" data-step="4">Get Signup Code</button>
<span class="step-status" data-step="4"></span>
</div>
<div class="step-row" data-step="5">
<div class="step-indicator" data-step="5"><span class="step-num">5</span></div>
<button class="step-btn" data-step="5">Fill Name / Birthday</button>
<span class="step-status" data-step="5"></span>
</div>
<div class="step-row" data-step="6">
<div class="step-indicator" data-step="6"><span class="step-num">6</span></div>
<button class="step-btn" data-step="6">Login via OAuth</button>
<span class="step-status" data-step="6"></span>
</div>
<div class="step-row" data-step="7">
<div class="step-indicator" data-step="7"><span class="step-num">7</span></div>
<button class="step-btn" data-step="7">Get Login Code</button>
<span class="step-status" data-step="7"></span>
</div>
<div class="step-row" data-step="8">
<div class="step-indicator" data-step="8"><span class="step-num">8</span></div>
<button class="step-btn" data-step="8">OAuth Auto Confirm</button>
<span class="step-status" data-step="8"></span>
</div>
<div class="step-row" data-step="9">
<div class="step-indicator" data-step="9"><span class="step-num">9</span></div>
<button class="step-btn" data-step="9">Cleanup Email</button>
<span class="step-status" data-step="9"></span>
</div>
</div>
</section>
<section id="log-section">
<div class="log-header">
<span class="section-label">Console</span>
<button id="btn-clear-log" class="btn btn-ghost btn-xs" title="Clear log">Clear</button>
</div>
<div id="log-area"></div>
</section>
<div id="toast-container"></div>
<script src="../shared/email-provider.js"></script>
<script src="sidepanel.js"></script>
</body>
</html>
@@ -0,0 +1,636 @@
// sidepanel/sidepanel.js — Side Panel logic
const STATUS_ICONS = {
pending: '',
running: '',
completed: '\u2713', // ✓
failed: '\u2717', // ✗
stopped: '\u25A0', // ■
};
const logArea = document.getElementById('log-area');
const displayOauthUrl = document.getElementById('display-oauth-url');
const displayLocalhostUrl = document.getElementById('display-localhost-url');
const displayStatus = document.getElementById('display-status');
const statusBar = document.getElementById('status-bar');
const inputEmail = document.getElementById('input-email');
const inputPassword = document.getElementById('input-password');
const btnFetchEmail = document.getElementById('btn-fetch-email');
const autoHint = document.getElementById('auto-hint');
const btnTogglePassword = document.getElementById('btn-toggle-password');
const btnStop = document.getElementById('btn-stop');
const btnReset = document.getElementById('btn-reset');
const stepsProgress = document.getElementById('steps-progress');
const btnAutoRun = document.getElementById('btn-auto-run');
const btnAutoContinue = document.getElementById('btn-auto-continue');
const autoContinueBar = document.getElementById('auto-continue-bar');
const btnClearLog = document.getElementById('btn-clear-log');
const inputVpsUrl = document.getElementById('input-vps-url');
const selectMailProvider = document.getElementById('select-mail-provider');
const selectEmailProvider = document.getElementById('select-email-provider');
const rowCloudflareTempEmailUrl = document.getElementById('row-cloudflare-temp-email-url');
const inputCloudflareTempEmailUrl = document.getElementById('input-cloudflare-temp-email-url');
const rowInbucketHost = document.getElementById('row-inbucket-host');
const inputInbucketHost = document.getElementById('input-inbucket-host');
const rowInbucketMailbox = document.getElementById('row-inbucket-mailbox');
const inputInbucketMailbox = document.getElementById('input-inbucket-mailbox');
const inputRunCount = document.getElementById('input-run-count');
const {
DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL = 'https://mail.cloudflare.com/admin',
EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL = 'cloudflare_temp_email',
EMAIL_PROVIDER_DUCK = 'duckduckgo',
EMAIL_PROVIDER_RELAY_FIREFOX = 'relay_firefox',
getEmailProviderDisplayName = (value) => value === 'relay_firefox'
? 'Firefox Relay'
: value === 'cloudflare_temp_email'
? 'Cloudflare Temp Email'
: 'DuckDuckGo',
normalizeCloudflareTempEmailAdminUrl = (value) => value || DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL,
normalizeEmailProvider = (value) => {
if (value === 'relay_firefox') return 'relay_firefox';
if (value === 'cloudflare_temp_email') return 'cloudflare_temp_email';
return 'duckduckgo';
},
} = globalThis.MultiPageEmailProvider || {};
// ============================================================
// Toast Notifications
// ============================================================
const toastContainer = document.getElementById('toast-container');
const TOAST_ICONS = {
error: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>',
warn: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
success: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
info: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
};
function showToast(message, type = 'error', duration = 4000) {
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.innerHTML = `${TOAST_ICONS[type] || ''}<span class="toast-msg">${escapeHtml(message)}</span><button class="toast-close">&times;</button>`;
toast.querySelector('.toast-close').addEventListener('click', () => dismissToast(toast));
toastContainer.appendChild(toast);
if (duration > 0) {
setTimeout(() => dismissToast(toast), duration);
}
}
function dismissToast(toast) {
if (!toast.parentNode) return;
toast.classList.add('toast-exit');
toast.addEventListener('animationend', () => toast.remove());
}
// ============================================================
// State Restore on load
// ============================================================
async function restoreState() {
try {
const state = await chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' });
if (state.oauthUrl) {
displayOauthUrl.textContent = state.oauthUrl;
displayOauthUrl.classList.add('has-value');
}
if (state.localhostUrl) {
displayLocalhostUrl.textContent = state.localhostUrl;
displayLocalhostUrl.classList.add('has-value');
}
if (state.email) {
inputEmail.value = state.email;
}
syncPasswordField(state);
if (state.vpsUrl) {
inputVpsUrl.value = state.vpsUrl;
}
if (state.mailProvider) {
selectMailProvider.value = state.mailProvider;
}
if (state.emailProvider) {
selectEmailProvider.value = normalizeEmailProvider(state.emailProvider);
}
if (state.cloudflareTempEmailAdminUrl) {
inputCloudflareTempEmailUrl.value = state.cloudflareTempEmailAdminUrl;
}
if (state.inbucketHost) {
inputInbucketHost.value = state.inbucketHost;
}
if (state.inbucketMailbox) {
inputInbucketMailbox.value = state.inbucketMailbox;
}
if (state.stepStatuses) {
for (const [step, status] of Object.entries(state.stepStatuses)) {
updateStepUI(Number(step), status);
}
}
if (state.logs) {
for (const entry of state.logs) {
appendLog(entry);
}
}
updateStatusDisplay(state);
updateProgressCounter();
updateMailProviderUI();
updateAutoContinueHint();
} catch (err) {
console.error('Failed to restore state:', err);
}
}
function syncPasswordField(state) {
inputPassword.value = state.customPassword || state.password || '';
}
function updateMailProviderUI() {
const useInbucket = selectMailProvider.value === 'inbucket';
const useCloudflareTempEmail = getSelectedEmailProvider() === EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL;
inputCloudflareTempEmailUrl.placeholder = DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL;
rowCloudflareTempEmailUrl.style.display = useCloudflareTempEmail ? '' : 'none';
rowInbucketHost.style.display = useInbucket ? '' : 'none';
rowInbucketMailbox.style.display = useInbucket ? '' : 'none';
}
function getSelectedEmailProvider() {
return normalizeEmailProvider(selectEmailProvider.value);
}
function getEmailProviderName(provider = getSelectedEmailProvider()) {
return getEmailProviderDisplayName(provider);
}
function updateAutoContinueHint() {
const provider = getSelectedEmailProvider();
if (!autoHint) return;
if (provider === EMAIL_PROVIDER_RELAY_FIREFOX) {
autoHint.textContent = 'Use Auto to create a Relay mask, or paste manually, then continue';
return;
}
if (provider === EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL) {
const configuredUrl = normalizeCloudflareTempEmailAdminUrl(inputCloudflareTempEmailUrl.value.trim());
autoHint.textContent = `Use Auto to create a Cloudflare Temp Email mailbox from ${configuredUrl}, or paste an existing admin mailbox, then continue`;
return;
}
autoHint.textContent = 'Use Auto to fetch Duck email, or paste manually, then continue';
}
// ============================================================
// UI Updates
// ============================================================
function updateStepUI(step, status) {
const statusEl = document.querySelector(`.step-status[data-step="${step}"]`);
const row = document.querySelector(`.step-row[data-step="${step}"]`);
if (statusEl) statusEl.textContent = STATUS_ICONS[status] || '';
if (row) {
row.className = `step-row ${status}`;
}
updateButtonStates();
updateProgressCounter();
}
function updateProgressCounter() {
let completed = 0;
document.querySelectorAll('.step-row').forEach(row => {
if (row.classList.contains('completed')) completed++;
});
stepsProgress.textContent = `${completed} / 9`;
}
function updateButtonStates() {
const statuses = {};
document.querySelectorAll('.step-row').forEach(row => {
const step = Number(row.dataset.step);
if (row.classList.contains('completed')) statuses[step] = 'completed';
else if (row.classList.contains('running')) statuses[step] = 'running';
else if (row.classList.contains('failed')) statuses[step] = 'failed';
else if (row.classList.contains('stopped')) statuses[step] = 'stopped';
else statuses[step] = 'pending';
});
const anyRunning = Object.values(statuses).some(s => s === 'running');
for (let step = 1; step <= 9; step++) {
const btn = document.querySelector(`.step-btn[data-step="${step}"]`);
if (!btn) continue;
if (anyRunning) {
btn.disabled = true;
} else if (step === 1) {
btn.disabled = false;
} else {
const prevStatus = statuses[step - 1];
const currentStatus = statuses[step];
btn.disabled = !(prevStatus === 'completed' || currentStatus === 'failed' || currentStatus === 'completed' || currentStatus === 'stopped');
}
}
updateStopButtonState(anyRunning || autoContinueBar.style.display !== 'none');
}
function updateStopButtonState(active) {
btnStop.disabled = !active;
}
function updateStatusDisplay(state) {
if (!state || !state.stepStatuses) return;
statusBar.className = 'status-bar';
const running = Object.entries(state.stepStatuses).find(([, s]) => s === 'running');
if (running) {
displayStatus.textContent = `Step ${running[0]} running...`;
statusBar.classList.add('running');
return;
}
const failed = Object.entries(state.stepStatuses).find(([, s]) => s === 'failed');
if (failed) {
displayStatus.textContent = `Step ${failed[0]} failed`;
statusBar.classList.add('failed');
return;
}
const stopped = Object.entries(state.stepStatuses).find(([, s]) => s === 'stopped');
if (stopped) {
displayStatus.textContent = `Step ${stopped[0]} stopped`;
statusBar.classList.add('stopped');
return;
}
const lastCompleted = Object.entries(state.stepStatuses)
.filter(([, s]) => s === 'completed')
.map(([k]) => Number(k))
.sort((a, b) => b - a)[0];
if (lastCompleted === 9) {
displayStatus.textContent = 'All steps completed!';
statusBar.classList.add('completed');
} else if (lastCompleted) {
displayStatus.textContent = `Step ${lastCompleted} done`;
} else {
displayStatus.textContent = 'Ready';
}
}
function appendLog(entry) {
const time = new Date(entry.timestamp).toLocaleTimeString('en-US', { hour12: false });
const levelLabel = entry.level.toUpperCase();
const line = document.createElement('div');
line.className = `log-line log-${entry.level}`;
const stepMatch = entry.message.match(/Step (\d)/);
const stepNum = stepMatch ? stepMatch[1] : null;
let html = `<span class="log-time">${time}</span> `;
html += `<span class="log-level log-level-${entry.level}">${levelLabel}</span> `;
if (stepNum) {
html += `<span class="log-step-tag step-${stepNum}">S${stepNum}</span>`;
}
html += `<span class="log-msg">${escapeHtml(entry.message)}</span>`;
line.innerHTML = html;
logArea.appendChild(line);
logArea.scrollTop = logArea.scrollHeight;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function fetchSelectedEmail() {
const defaultLabel = 'Auto';
const provider = getSelectedEmailProvider();
btnFetchEmail.disabled = true;
btnFetchEmail.textContent = '...';
try {
const response = await chrome.runtime.sendMessage({
type: 'FETCH_PROVIDER_EMAIL',
source: 'sidepanel',
payload: { provider, generateNew: true },
});
if (response?.error) {
throw new Error(response.error);
}
if (!response?.email) {
throw new Error('Provider email was not returned.');
}
inputEmail.value = response.email;
showToast(`Fetched ${response.email}`, 'success', 2500);
return response.email;
} catch (err) {
showToast(`Auto fetch failed: ${err.message}`, 'error');
throw err;
} finally {
btnFetchEmail.disabled = false;
btnFetchEmail.textContent = defaultLabel;
}
}
function syncPasswordToggleLabel() {
btnTogglePassword.textContent = inputPassword.type === 'password' ? 'Show' : 'Hide';
}
// ============================================================
// Button Handlers
// ============================================================
document.querySelectorAll('.step-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const step = Number(btn.dataset.step);
if (step === 3) {
const provider = getSelectedEmailProvider();
const email = inputEmail.value.trim();
if (provider === EMAIL_PROVIDER_DUCK && !email) {
showToast('Please paste email address or use Auto first', 'warn');
return;
}
const payload = provider === EMAIL_PROVIDER_DUCK ? { step, email } : { step };
await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload });
} else {
await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } });
}
});
});
btnFetchEmail.addEventListener('click', async () => {
await fetchSelectedEmail().catch(() => {});
});
btnTogglePassword.addEventListener('click', () => {
inputPassword.type = inputPassword.type === 'password' ? 'text' : 'password';
syncPasswordToggleLabel();
});
btnStop.addEventListener('click', async () => {
btnStop.disabled = true;
await chrome.runtime.sendMessage({ type: 'STOP_FLOW', source: 'sidepanel', payload: {} });
showToast('Stopping current flow...', 'warn', 2000);
});
// Auto Run
btnAutoRun.addEventListener('click', async () => {
const totalRuns = parseInt(inputRunCount.value) || 1;
btnAutoRun.disabled = true;
inputRunCount.disabled = true;
btnAutoRun.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg> Running...';
await chrome.runtime.sendMessage({ type: 'AUTO_RUN', source: 'sidepanel', payload: { totalRuns } });
});
btnAutoContinue.addEventListener('click', async () => {
const provider = getSelectedEmailProvider();
const email = inputEmail.value.trim();
if (!email) {
showToast(`Please fetch or paste ${getEmailProviderName(provider)} email first!`, 'warn');
return;
}
autoContinueBar.style.display = 'none';
await chrome.runtime.sendMessage({ type: 'RESUME_AUTO_RUN', source: 'sidepanel', payload: { email } });
});
// Reset
btnReset.addEventListener('click', async () => {
if (confirm('Reset all steps and data?')) {
await chrome.runtime.sendMessage({ type: 'RESET', source: 'sidepanel' });
displayOauthUrl.textContent = 'Waiting...';
displayOauthUrl.classList.remove('has-value');
displayLocalhostUrl.textContent = 'Waiting...';
displayLocalhostUrl.classList.remove('has-value');
inputEmail.value = '';
displayStatus.textContent = 'Ready';
statusBar.className = 'status-bar';
logArea.innerHTML = '';
document.querySelectorAll('.step-row').forEach(row => row.className = 'step-row');
document.querySelectorAll('.step-status').forEach(el => el.textContent = '');
btnAutoRun.disabled = false;
inputRunCount.disabled = false;
btnAutoRun.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> Auto';
autoContinueBar.style.display = 'none';
updateStopButtonState(false);
updateButtonStates();
updateProgressCounter();
}
});
// Clear log
btnClearLog.addEventListener('click', () => {
logArea.innerHTML = '';
});
// Save settings on change
inputEmail.addEventListener('change', async () => {
const email = inputEmail.value.trim();
if (email) {
await chrome.runtime.sendMessage({ type: 'SAVE_EMAIL', source: 'sidepanel', payload: { email } });
}
});
inputVpsUrl.addEventListener('change', async () => {
const vpsUrl = inputVpsUrl.value.trim();
if (vpsUrl) {
await chrome.runtime.sendMessage({ type: 'SAVE_SETTING', source: 'sidepanel', payload: { vpsUrl } });
}
});
inputPassword.addEventListener('change', async () => {
await chrome.runtime.sendMessage({
type: 'SAVE_SETTING',
source: 'sidepanel',
payload: { customPassword: inputPassword.value },
});
});
selectMailProvider.addEventListener('change', async () => {
updateMailProviderUI();
await chrome.runtime.sendMessage({
type: 'SAVE_SETTING', source: 'sidepanel',
payload: { mailProvider: selectMailProvider.value },
});
});
selectEmailProvider.addEventListener('change', async () => {
updateMailProviderUI();
updateAutoContinueHint();
await chrome.runtime.sendMessage({
type: 'SAVE_SETTING',
source: 'sidepanel',
payload: { emailProvider: getSelectedEmailProvider() },
});
});
inputCloudflareTempEmailUrl.addEventListener('change', async () => {
updateAutoContinueHint();
await chrome.runtime.sendMessage({
type: 'SAVE_SETTING',
source: 'sidepanel',
payload: { cloudflareTempEmailAdminUrl: inputCloudflareTempEmailUrl.value.trim() },
});
});
inputInbucketMailbox.addEventListener('change', async () => {
await chrome.runtime.sendMessage({
type: 'SAVE_SETTING',
source: 'sidepanel',
payload: { inbucketMailbox: inputInbucketMailbox.value.trim() },
});
});
inputInbucketHost.addEventListener('change', async () => {
await chrome.runtime.sendMessage({
type: 'SAVE_SETTING',
source: 'sidepanel',
payload: { inbucketHost: inputInbucketHost.value.trim() },
});
});
// ============================================================
// Listen for Background broadcasts
// ============================================================
chrome.runtime.onMessage.addListener((message) => {
switch (message.type) {
case 'LOG_ENTRY':
appendLog(message.payload);
if (message.payload.level === 'error') {
showToast(message.payload.message, 'error');
}
break;
case 'STEP_STATUS_CHANGED': {
const { step, status } = message.payload;
updateStepUI(step, status);
chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' }).then(updateStatusDisplay);
if (status === 'completed') {
chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' }).then(state => {
syncPasswordField(state);
if (state.oauthUrl) {
displayOauthUrl.textContent = state.oauthUrl;
displayOauthUrl.classList.add('has-value');
}
if (state.localhostUrl) {
displayLocalhostUrl.textContent = state.localhostUrl;
displayLocalhostUrl.classList.add('has-value');
}
});
}
break;
}
case 'AUTO_RUN_RESET': {
// Full UI reset for next run
displayOauthUrl.textContent = 'Waiting...';
displayOauthUrl.classList.remove('has-value');
displayLocalhostUrl.textContent = 'Waiting...';
displayLocalhostUrl.classList.remove('has-value');
inputEmail.value = '';
displayStatus.textContent = 'Ready';
statusBar.className = 'status-bar';
logArea.innerHTML = '';
document.querySelectorAll('.step-row').forEach(row => row.className = 'step-row');
document.querySelectorAll('.step-status').forEach(el => el.textContent = '');
updateStopButtonState(false);
updateProgressCounter();
break;
}
case 'DATA_UPDATED': {
if (message.payload.email) {
inputEmail.value = message.payload.email;
}
if (message.payload.password !== undefined) {
inputPassword.value = message.payload.password || '';
}
if (message.payload.oauthUrl) {
displayOauthUrl.textContent = message.payload.oauthUrl;
displayOauthUrl.classList.add('has-value');
}
if (message.payload.localhostUrl) {
displayLocalhostUrl.textContent = message.payload.localhostUrl;
displayLocalhostUrl.classList.add('has-value');
}
break;
}
case 'AUTO_RUN_STATUS': {
const { phase, currentRun, totalRuns } = message.payload;
const runLabel = totalRuns > 1 ? ` (${currentRun}/${totalRuns})` : '';
switch (phase) {
case 'waiting_email':
autoContinueBar.style.display = 'flex';
btnAutoRun.innerHTML = `Paused${runLabel}`;
updateStopButtonState(true);
break;
case 'running':
btnAutoRun.innerHTML = `Running${runLabel}`;
updateStopButtonState(true);
break;
case 'complete':
btnAutoRun.disabled = false;
inputRunCount.disabled = false;
btnAutoRun.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> Auto';
autoContinueBar.style.display = 'none';
updateStopButtonState(false);
break;
case 'stopped':
btnAutoRun.disabled = false;
inputRunCount.disabled = false;
btnAutoRun.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> Auto';
autoContinueBar.style.display = 'none';
updateStopButtonState(false);
break;
}
break;
}
}
});
// ============================================================
// Theme Toggle
// ============================================================
const btnTheme = document.getElementById('btn-theme');
function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('multipage-theme', theme);
}
function initTheme() {
const saved = localStorage.getItem('multipage-theme');
if (saved) {
setTheme(saved);
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
setTheme('dark');
}
}
btnTheme.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme');
setTheme(current === 'dark' ? 'light' : 'dark');
});
// ============================================================
// Init
// ============================================================
initTheme();
restoreState().then(() => {
syncPasswordToggleLabel();
updateButtonStates();
});
@@ -0,0 +1,221 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
combineDistinctTextParts,
extractVerificationCode,
generateReadableLocalPart,
parseAdminTimestamp,
parseCloudflareMailboxCredential,
pickRandomSuffix,
selectVerificationMessage,
} = require('../shared/cloudflare-temp-email.js');
function createJwt(payload) {
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
return `${header}.${body}.signature`;
}
test('parseCloudflareMailboxCredential decodes email and address id from JWT token', () => {
const token = createJwt({
address: 'newmask@co.example.test',
address_id: 42,
});
assert.deepEqual(parseCloudflareMailboxCredential(token), {
addressId: 42,
domain: 'co.example.test',
email: 'newmask@co.example.test',
localPart: 'newmask',
provenance: 'created',
});
});
test('parseAdminTimestamp parses admin timestamps into epoch milliseconds', () => {
const value = parseAdminTimestamp('2026/4/7 10:33:07');
assert.equal(Number.isFinite(value), true);
assert.equal(new Date(value).getFullYear(), 2026);
});
test('extractVerificationCode reads six-digit codes from mixed-language subjects', () => {
assert.equal(extractVerificationCode('Your ChatGPT code is 377680'), '377680');
assert.equal(extractVerificationCode('你的 ChatGPT 代码为 479637,请勿泄露。'), '479637');
});
test('combineDistinctTextParts collapses duplicated text fragments from DOM sources', () => {
const value = combineDistinctTextParts([
'账号',
'账号',
' 账号 ',
'',
null,
]);
assert.equal(value, '账号');
});
test('combineDistinctTextParts keeps distinct fragments in order', () => {
const value = combineDistinctTextParts([
'邮箱地址凭证',
'token-value',
'token-value',
'关闭',
]);
assert.equal(value, '邮箱地址凭证 token-value 关闭');
});
test('generateReadableLocalPart creates three lowercase hyphenated words', () => {
const value = generateReadableLocalPart(() => 0);
assert.match(value, /^[a-z]+-[a-z]+-[a-z]+$/);
assert.equal(value.split('-').length, 3);
});
test('generateReadableLocalPart is deterministic for a fixed random sequence', () => {
const sequence = [0.02, 0.31, 0.58];
let index = 0;
const value = generateReadableLocalPart(() => sequence[index++]);
assert.equal(value, 'anew-dotted-latch');
});
test('generateReadableLocalPart retries until the generated local part fits the max length', () => {
const sequence = [
0.98, 0.98, 0.98,
0.02, 0.31, 0.58,
];
let index = 0;
const value = generateReadableLocalPart(() => sequence[index++], 20);
assert.equal(value.length <= 20, true);
assert.equal(value, 'anew-dotted-latch');
});
test('generateReadableLocalPart can use newly added higher-range words', () => {
const sequence = [0.9, 0.9, 0.9];
let index = 0;
const value = generateReadableLocalPart(() => sequence[index++]);
assert.equal(value, 'vantage-velvet-zephyr');
});
test('generateReadableLocalPart can use extended top-range words', () => {
const sequence = [0.97, 0.97, 0.97];
let index = 0;
const value = generateReadableLocalPart(() => sequence[index++]);
assert.equal(value, 'whimsy-marbled-solstice');
});
test('generateReadableLocalPart can use extended mid-range words', () => {
const sequence = [0.962, 0.962, 0.962];
let index = 0;
const value = generateReadableLocalPart(() => sequence[index++]);
assert.equal(value, 'ivory-rusted-starling');
});
test('generateReadableLocalPart can use extended apex-range words', () => {
const sequence = [0.993, 0.993, 0.993];
let index = 0;
const value = generateReadableLocalPart(() => sequence[index++]);
assert.equal(value, 'atlas-bronze-cosmos');
});
test('pickRandomSuffix selects a deterministic suffix from the available options', () => {
const value = pickRandomSuffix([
'co.example.test',
'de.example.test',
'ice.example.test',
'work.example.test',
], () => 0.74);
assert.equal(value, 'ice.example.test');
});
test('pickRandomSuffix ignores empty and duplicate suffix values', () => {
const value = pickRandomSuffix([
' co.example.test ',
'',
null,
'de.example.test',
'@ICE.example.test',
'de.example.test',
], () => 0.9);
assert.equal(value, 'ice.example.test');
});
test('selectVerificationMessage ignores messages for other recipients', () => {
const result = selectVerificationMessage([
{
combinedText: 'Your ChatGPT code is 123456',
emailTimestamp: parseAdminTimestamp('2026/4/7 10:33:07'),
matchedEmail: 'someone-else@co.example.test',
messageId: '11',
subject: 'Your ChatGPT code is 123456',
},
], {
filterAfterTimestamp: 0,
senderFilters: ['openai'],
subjectFilters: ['code'],
targetEmail: 'target@co.example.test',
});
assert.equal(result, null);
});
test('selectVerificationMessage requires a strictly newer timestamp than filterAfterTimestamp', () => {
const ts = parseAdminTimestamp('2026/4/7 10:33:07');
const result = selectVerificationMessage([
{
combinedText: 'Enter this temporary verification code to continue: 377680',
emailTimestamp: ts,
matchedEmail: 'target@co.example.test',
messageId: '11',
subject: 'Your ChatGPT code is 377680',
},
], {
filterAfterTimestamp: ts,
senderFilters: ['openai'],
subjectFilters: ['code'],
targetEmail: 'target@co.example.test',
});
assert.equal(result, null);
});
test('selectVerificationMessage picks the newest matching message after the threshold', () => {
const result = selectVerificationMessage([
{
combinedText: '旧验证码 111111',
emailTimestamp: parseAdminTimestamp('2026/4/7 10:30:00'),
matchedEmail: 'target@co.example.test',
messageId: '8',
subject: 'Your ChatGPT code is 111111',
},
{
combinedText: 'Enter this temporary verification code to continue: 377680',
emailTimestamp: parseAdminTimestamp('2026/4/7 10:33:07'),
matchedEmail: 'target@co.example.test',
messageId: '11',
subject: 'Your ChatGPT code is 377680',
},
], {
filterAfterTimestamp: parseAdminTimestamp('2026/4/7 10:31:00'),
senderFilters: ['openai'],
subjectFilters: ['code'],
targetEmail: 'target@co.example.test',
});
assert.deepEqual(result, {
code: '377680',
emailTimestamp: parseAdminTimestamp('2026/4/7 10:33:07'),
matchedEmail: 'target@co.example.test',
messageId: '11',
subject: 'Your ChatGPT code is 377680',
});
});
@@ -0,0 +1,106 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL,
EMAIL_PROVIDER_DUCK,
EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL,
EMAIL_PROVIDER_RELAY_FIREFOX,
getEmailProviderDisplayName,
getNextRelayMaskLabel,
isCloudflareTempEmailProvider,
normalizeCloudflareTempEmailAdminUrl,
normalizeEmailProvider,
shouldUseEmailSourceForVerification,
shouldSkipStep9Cleanup,
} = require('../shared/email-provider.js');
test('normalizeEmailProvider keeps relay_firefox as-is', () => {
assert.equal(normalizeEmailProvider('relay_firefox'), EMAIL_PROVIDER_RELAY_FIREFOX);
});
test('normalizeEmailProvider keeps cloudflare_temp_email as-is', () => {
assert.equal(
normalizeEmailProvider('cloudflare_temp_email'),
EMAIL_PROVIDER_CLOUDFLARE_TEMP_EMAIL
);
});
test('normalizeEmailProvider falls back to duckduckgo for unknown values', () => {
assert.equal(normalizeEmailProvider('something-else'), EMAIL_PROVIDER_DUCK);
});
test('isCloudflareTempEmailProvider identifies cloudflare_temp_email', () => {
assert.equal(isCloudflareTempEmailProvider('cloudflare_temp_email'), true);
});
test('isCloudflareTempEmailProvider rejects relay_firefox', () => {
assert.equal(isCloudflareTempEmailProvider('relay_firefox'), false);
});
test('getEmailProviderDisplayName returns Cloudflare Temp Email label', () => {
assert.equal(
getEmailProviderDisplayName('cloudflare_temp_email'),
'Cloudflare Temp Email'
);
});
test('DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL uses the public open-source-safe admin URL', () => {
assert.equal(
DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL,
'https://mail.cloudflare.com/admin'
);
});
test('normalizeCloudflareTempEmailAdminUrl falls back to the default URL for empty values', () => {
assert.equal(
normalizeCloudflareTempEmailAdminUrl(''),
DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL
);
});
test('normalizeCloudflareTempEmailAdminUrl trims whitespace and prepends https when protocol is missing', () => {
assert.equal(
normalizeCloudflareTempEmailAdminUrl(' custom.example.com/admin '),
'https://custom.example.com/admin'
);
});
test('normalizeCloudflareTempEmailAdminUrl normalizes the default admin URL path', () => {
assert.equal(
normalizeCloudflareTempEmailAdminUrl('https://mail.cloudflare.com/admin/'),
DEFAULT_CLOUDFLARE_TEMP_EMAIL_ADMIN_URL
);
});
test('getNextRelayMaskLabel returns t1 when there are no existing labels', () => {
assert.equal(getNextRelayMaskLabel([]), 't1');
});
test('getNextRelayMaskLabel fills the first numeric gap', () => {
assert.equal(getNextRelayMaskLabel(['t1', 'hello', 't3']), 't2');
});
test('shouldSkipStep9Cleanup returns false for relay_firefox', () => {
assert.equal(shouldSkipStep9Cleanup('relay_firefox'), false);
});
test('shouldUseEmailSourceForVerification returns true for cloudflare_temp_email', () => {
assert.equal(shouldUseEmailSourceForVerification('cloudflare_temp_email'), true);
});
test('shouldUseEmailSourceForVerification returns false for relay_firefox', () => {
assert.equal(shouldUseEmailSourceForVerification('relay_firefox'), false);
});
test('shouldUseEmailSourceForVerification returns false for duckduckgo', () => {
assert.equal(shouldUseEmailSourceForVerification('duckduckgo'), false);
});
test('shouldSkipStep9Cleanup returns true for duckduckgo', () => {
assert.equal(shouldSkipStep9Cleanup('duckduckgo'), true);
});
test('shouldSkipStep9Cleanup returns true for cloudflare_temp_email', () => {
assert.equal(shouldSkipStep9Cleanup('cloudflare_temp_email'), true);
});
@@ -0,0 +1,102 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
hasAnyConsentPageState,
findLoopbackCallbackUrl,
isConsentUrl,
isConsentPageState,
isLoopbackCallbackUrl,
} = require('../shared/oauth-flow.js');
test('isConsentUrl matches the exact known consent URL', () => {
assert.equal(
isConsentUrl('https://auth.openai.com/sign-in-with-chatgpt/codex/consent'),
true
);
});
test('isConsentUrl rejects unrelated auth routes', () => {
assert.equal(
isConsentUrl('https://auth.openai.com/u/signup/identifier'),
false
);
});
test('isConsentPageState accepts sign-in-with-chatgpt routes when a continue button is visible', () => {
assert.equal(
isConsentPageState({
url: 'https://auth.openai.com/sign-in-with-chatgpt/codex/consent?state=abc',
hasVisibleContinueButton: true,
}),
true
);
});
test('isConsentPageState rejects sign-in-with-chatgpt routes without a visible continue button when URL is not exact', () => {
assert.equal(
isConsentPageState({
url: 'https://auth.openai.com/sign-in-with-chatgpt/codex/checkpoint',
hasVisibleContinueButton: false,
}),
false
);
});
test('hasAnyConsentPageState returns true when consent appears after an initial non-consent state', () => {
assert.equal(
hasAnyConsentPageState([
{
url: 'https://auth.openai.com/u/signup/profile',
hasVisibleContinueButton: false,
},
{
url: 'https://auth.openai.com/sign-in-with-chatgpt/codex/consent?state=abc',
hasVisibleContinueButton: true,
},
]),
true
);
});
test('isLoopbackCallbackUrl accepts localhost callback URLs', () => {
assert.equal(
isLoopbackCallbackUrl('http://localhost:1455/auth/callback?code=abc&state=123'),
true
);
});
test('isLoopbackCallbackUrl accepts 127.0.0.1 callback URLs', () => {
assert.equal(
isLoopbackCallbackUrl('http://127.0.0.1:8317/codex/callback?code=abc&state=123'),
true
);
});
test('isLoopbackCallbackUrl rejects non-loopback callback URLs', () => {
assert.equal(
isLoopbackCallbackUrl('https://example.com/callback?code=abc&state=123'),
false
);
});
test('findLoopbackCallbackUrl returns the first loopback callback URL from candidates', () => {
assert.equal(
findLoopbackCallbackUrl([
'https://auth.openai.com/sign-in-with-chatgpt/codex/consent',
'http://127.0.0.1:8317/codex/callback?code=abc&state=123',
'http://localhost:1455/auth/callback?code=def&state=456',
]),
'http://127.0.0.1:8317/codex/callback?code=abc&state=123'
);
});
test('findLoopbackCallbackUrl returns null when no loopback callback URL exists', () => {
assert.equal(
findLoopbackCallbackUrl([
'https://auth.openai.com/sign-in-with-chatgpt/codex/consent',
'https://example.com/callback?code=abc&state=123',
]),
null
);
});
@@ -0,0 +1,59 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
extractVerificationCode,
findNewQQVerificationCode,
} = require('../shared/qq-mail.js');
test('extractVerificationCode reads 6-digit codes from QQ mail text', () => {
assert.equal(
extractVerificationCode('你的 ChatGPT 代码为 479637,请勿泄露。'),
'479637'
);
});
test('findNewQQVerificationCode rejects matching emails that already existed before polling', () => {
const result = findNewQQVerificationCode([
{
mailId: 'old-1',
sender: 'OpenAI',
subject: '你的 ChatGPT 代码为 479637',
digest: '用于验证你的邮箱地址',
},
], {
existingMailIds: ['old-1'],
senderFilters: ['openai', 'verify'],
subjectFilters: ['code', '验证'],
});
assert.equal(result, null);
});
test('findNewQQVerificationCode accepts the first new matching email', () => {
const result = findNewQQVerificationCode([
{
mailId: 'old-1',
sender: 'OpenAI',
subject: '你的 ChatGPT 代码为 111111',
digest: '旧邮件',
},
{
mailId: 'new-1',
sender: 'OpenAI',
subject: '你的 ChatGPT 代码为 222222',
digest: '新邮件',
},
], {
existingMailIds: ['old-1'],
senderFilters: ['openai', 'verify'],
subjectFilters: ['code', '验证'],
});
assert.deepEqual(result, {
code: '222222',
mailId: 'new-1',
source: 'new',
subject: '你的 ChatGPT 代码为 222222',
});
});