fix: align Hotmail API integration with current dev
- merge the latest dev changes into PR #69 before continuing the review flow - keep Microsoft API mode while preserving mailbox-by-mailbox polling and verification filters - update the Hotmail side panel copy and helper coverage for the repaired API path
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
/docs/md
|
/docs/md
|
||||||
|
|
||||||
/.github
|
/.github
|
||||||
|
/_metadata/
|
||||||
|
|||||||
@@ -492,13 +492,27 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
|
|||||||
|
|
||||||
在登录前会先重新获取一遍最新的 CPA OAuth 链接,再使用刚注册的账号登录。
|
在登录前会先重新获取一遍最新的 CPA OAuth 链接,再使用刚注册的账号登录。
|
||||||
|
|
||||||
|
当前 Step 6 的完成标准不是“邮箱/密码已提交”,而是:
|
||||||
|
|
||||||
|
- 认证页已经真正进入登录验证码页面
|
||||||
|
- 如遇登录超时报错或登录页长时间停滞,会由后台刷新 OAuth 后重跑整个 Step 6
|
||||||
|
|
||||||
支持:
|
支持:
|
||||||
|
|
||||||
- 邮箱 + 密码登录
|
- 邮箱 + 密码登录
|
||||||
- 提交后进入验证码验证流程
|
- 必要时切换到一次性验证码登录
|
||||||
|
- 直到登录验证码页就绪才算步骤完成
|
||||||
|
|
||||||
### Step 7: Get Login Code
|
### Step 7: Get Login Code
|
||||||
|
|
||||||
|
Step 7 默认要求当前认证页已经处于登录验证码页。
|
||||||
|
|
||||||
|
它只负责:
|
||||||
|
|
||||||
|
- 打开邮箱并轮询登录验证码
|
||||||
|
- 填写并提交登录验证码
|
||||||
|
- 验证码链路失败后按有限次数回退到 Step 6
|
||||||
|
|
||||||
与 Step 4 类似,但会使用稍微不同的关键词组合去找登录验证码邮件。
|
与 Step 4 类似,但会使用稍微不同的关键词组合去找登录验证码邮件。
|
||||||
|
|
||||||
### Step 8: Manual OAuth Confirm
|
### Step 8: Manual OAuth Confirm
|
||||||
|
|||||||
+2229
-165
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,400 @@
|
|||||||
|
(function cloudflareTempEmailUtilsModule(root, factory) {
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = factory();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.CloudflareTempEmailUtils = factory();
|
||||||
|
})(typeof self !== 'undefined' ? self : globalThis, function createCloudflareTempEmailUtils() {
|
||||||
|
const DEFAULT_MAIL_PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
function firstNonEmptyString(values) {
|
||||||
|
for (const value of values) {
|
||||||
|
if (value === undefined || value === null) continue;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
if (normalized) return normalized;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCloudflareTempEmailBaseUrl(rawValue = '') {
|
||||||
|
const value = String(rawValue || '').trim();
|
||||||
|
if (!value) return '';
|
||||||
|
|
||||||
|
const candidate = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(value) ? value : `https://${value}`;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(candidate);
|
||||||
|
parsed.hash = '';
|
||||||
|
parsed.search = '';
|
||||||
|
const pathname = parsed.pathname === '/' ? '' : parsed.pathname.replace(/\/+$/, '');
|
||||||
|
return `${parsed.origin}${pathname}`;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCloudflareTempEmailDomain(rawValue = '') {
|
||||||
|
let value = String(rawValue || '').trim().toLowerCase();
|
||||||
|
if (!value) return '';
|
||||||
|
value = value.replace(/^@+/, '');
|
||||||
|
value = value.replace(/^https?:\/\//, '');
|
||||||
|
value = value.replace(/\/.*$/, '');
|
||||||
|
if (!/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(value)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCloudflareTempEmailDomains(values) {
|
||||||
|
const domains = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const value of Array.isArray(values) ? values : []) {
|
||||||
|
const normalized = normalizeCloudflareTempEmailDomain(value);
|
||||||
|
if (!normalized || seen.has(normalized)) continue;
|
||||||
|
seen.add(normalized);
|
||||||
|
domains.push(normalized);
|
||||||
|
}
|
||||||
|
return domains;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCloudflareTempEmailHeaders(config = {}, options = {}) {
|
||||||
|
const headers = {};
|
||||||
|
const adminAuth = firstNonEmptyString([config.adminAuth, config.cloudflareTempEmailAdminAuth]);
|
||||||
|
const customAuth = firstNonEmptyString([config.customAuth, config.cloudflareTempEmailCustomAuth]);
|
||||||
|
if (adminAuth) {
|
||||||
|
headers['x-admin-auth'] = adminAuth;
|
||||||
|
}
|
||||||
|
if (customAuth) {
|
||||||
|
headers['x-custom-auth'] = customAuth;
|
||||||
|
}
|
||||||
|
if (options.json) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
if (options.acceptJson !== false) {
|
||||||
|
headers.Accept = 'application/json';
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinCloudflareTempEmailUrl(baseUrl, path) {
|
||||||
|
const normalizedBase = normalizeCloudflareTempEmailBaseUrl(baseUrl);
|
||||||
|
const normalizedPath = String(path || '').trim();
|
||||||
|
if (!normalizedBase || !normalizedPath) return normalizedBase || '';
|
||||||
|
return `${normalizedBase}${normalizedPath.startsWith('/') ? '' : '/'}${normalizedPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCloudflareTempEmailMailRows(payload) {
|
||||||
|
if (Array.isArray(payload)) return payload;
|
||||||
|
if (!payload || typeof payload !== 'object') return [];
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
payload.data,
|
||||||
|
payload.items,
|
||||||
|
payload.messages,
|
||||||
|
payload.mails,
|
||||||
|
payload.results,
|
||||||
|
payload.rows,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (Array.isArray(candidate)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCloudflareTempEmailAddress(value) {
|
||||||
|
return String(value || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitRawMessage(raw = '') {
|
||||||
|
const source = String(raw || '');
|
||||||
|
if (!source) {
|
||||||
|
return { headerText: '', bodyText: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = source.replace(/\r\n/g, '\n');
|
||||||
|
const separatorIndex = normalized.indexOf('\n\n');
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
return { headerText: normalized, bodyText: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
headerText: normalized.slice(0, separatorIndex),
|
||||||
|
bodyText: normalized.slice(separatorIndex + 2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRawHeaders(headerText = '') {
|
||||||
|
const headers = {};
|
||||||
|
const lines = String(headerText || '').split('\n');
|
||||||
|
let currentName = '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line) continue;
|
||||||
|
if ((line.startsWith(' ') || line.startsWith('\t')) && currentName) {
|
||||||
|
headers[currentName] += ` ${line.trim()}`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const separatorIndex = line.indexOf(':');
|
||||||
|
if (separatorIndex <= 0) continue;
|
||||||
|
currentName = line.slice(0, separatorIndex).trim().toLowerCase();
|
||||||
|
headers[currentName] = line.slice(separatorIndex + 1).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeMimeEncodedWords(value = '') {
|
||||||
|
const source = String(value || '');
|
||||||
|
return source.replace(/=\?([^?]+)\?([bBqQ])\?([^?]+)\?=/g, (_match, charset, encoding, encodedText) => {
|
||||||
|
try {
|
||||||
|
if (String(encoding).toUpperCase() === 'B') {
|
||||||
|
return decodeBytesToString(base64ToBytes(encodedText), charset);
|
||||||
|
}
|
||||||
|
return decodeBytesToString(
|
||||||
|
quotedPrintableToBytes(String(encodedText).replace(/_/g, ' '), { headerMode: true }),
|
||||||
|
charset
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return encodedText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBytes(value = '') {
|
||||||
|
const normalized = String(value || '').replace(/\s+/g, '');
|
||||||
|
if (!normalized) return new Uint8Array();
|
||||||
|
|
||||||
|
if (typeof atob === 'function') {
|
||||||
|
const decoded = atob(normalized);
|
||||||
|
const bytes = new Uint8Array(decoded.length);
|
||||||
|
for (let i = 0; i < decoded.length; i += 1) {
|
||||||
|
bytes[i] = decoded.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof Buffer !== 'undefined') {
|
||||||
|
return Uint8Array.from(Buffer.from(normalized, 'base64'));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('No base64 decoder available');
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotedPrintableToBytes(value = '', options = {}) {
|
||||||
|
const { headerMode = false } = options;
|
||||||
|
const source = String(value || '')
|
||||||
|
.replace(/=\r?\n/g, '')
|
||||||
|
.replace(headerMode ? /_/g : /$^/, ' ');
|
||||||
|
|
||||||
|
const bytes = [];
|
||||||
|
for (let index = 0; index < source.length; index += 1) {
|
||||||
|
const char = source[index];
|
||||||
|
if (char === '=' && /^[0-9A-Fa-f]{2}$/.test(source.slice(index + 1, index + 3))) {
|
||||||
|
bytes.push(parseInt(source.slice(index + 1, index + 3), 16));
|
||||||
|
index += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
bytes.push(char.charCodeAt(0));
|
||||||
|
}
|
||||||
|
return Uint8Array.from(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBytesToString(bytes, charset = 'utf-8') {
|
||||||
|
const normalizedCharset = String(charset || 'utf-8').trim().toLowerCase();
|
||||||
|
const candidates = [normalizedCharset];
|
||||||
|
if (normalizedCharset === 'utf8') {
|
||||||
|
candidates.unshift('utf-8');
|
||||||
|
}
|
||||||
|
if (normalizedCharset === 'gb2312' || normalizedCharset === 'gbk') {
|
||||||
|
candidates.unshift('gb18030');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
if (typeof TextDecoder !== 'undefined') {
|
||||||
|
return new TextDecoder(candidate, { fatal: false }).decode(bytes);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore and try fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof Buffer !== 'undefined') {
|
||||||
|
return Buffer.from(bytes).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = '';
|
||||||
|
for (const byte of bytes) {
|
||||||
|
result += String.fromCharCode(byte);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCharsetFromContentType(contentType = '') {
|
||||||
|
const match = String(contentType || '').match(/charset="?([^";]+)"?/i);
|
||||||
|
return match ? match[1].trim() : 'utf-8';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBoundaryFromContentType(contentType = '') {
|
||||||
|
const match = String(contentType || '').match(/boundary="?([^";]+)"?/i);
|
||||||
|
return match ? match[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripHtmlTags(value = '') {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/ /gi, ' ')
|
||||||
|
.replace(/&/gi, '&')
|
||||||
|
.replace(/</gi, '<')
|
||||||
|
.replace(/>/gi, '>')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeMimeBody(bodyText = '', headers = {}) {
|
||||||
|
const contentType = String(headers['content-type'] || '');
|
||||||
|
const transferEncoding = String(headers['content-transfer-encoding'] || '').trim().toLowerCase();
|
||||||
|
const charset = getCharsetFromContentType(contentType);
|
||||||
|
let decoded = String(bodyText || '');
|
||||||
|
|
||||||
|
if (transferEncoding === 'base64') {
|
||||||
|
decoded = decodeBytesToString(base64ToBytes(decoded), charset);
|
||||||
|
} else if (transferEncoding === 'quoted-printable') {
|
||||||
|
decoded = decodeBytesToString(quotedPrintableToBytes(decoded), charset);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/text\/html/i.test(contentType)) {
|
||||||
|
return stripHtmlTags(decoded);
|
||||||
|
}
|
||||||
|
return decoded.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTextFromMime(rawMessage = '', depth = 0) {
|
||||||
|
const { headerText, bodyText } = splitRawMessage(rawMessage);
|
||||||
|
const headers = parseRawHeaders(headerText);
|
||||||
|
const contentType = String(headers['content-type'] || '');
|
||||||
|
const boundary = getBoundaryFromContentType(contentType);
|
||||||
|
|
||||||
|
if (/multipart\//i.test(contentType) && boundary && depth < 6) {
|
||||||
|
const marker = `--${boundary}`;
|
||||||
|
const sections = String(bodyText || '')
|
||||||
|
.split(marker)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter((part) => part && part !== '--');
|
||||||
|
|
||||||
|
const extractedParts = sections
|
||||||
|
.map((part) => part.replace(/--\s*$/, '').trim())
|
||||||
|
.map((part) => extractTextFromMime(part, depth + 1)?.text || '')
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const plainText = extractedParts.join(' ').replace(/\s+/g, ' ').trim();
|
||||||
|
return {
|
||||||
|
headers,
|
||||||
|
text: plainText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
headers,
|
||||||
|
text: decodeMimeBody(bodyText, headers),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeReceivedDateTime(value) {
|
||||||
|
if (!value && value !== 0) return '';
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return new Date(value).toISOString();
|
||||||
|
}
|
||||||
|
const source = String(value || '').trim();
|
||||||
|
if (!source) return '';
|
||||||
|
const parsed = Date.parse(source);
|
||||||
|
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : source;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCloudflareTempEmailMessage(row = {}) {
|
||||||
|
if (!row || typeof row !== 'object') return null;
|
||||||
|
|
||||||
|
const address = normalizeCloudflareTempEmailAddress(firstNonEmptyString([
|
||||||
|
row.address,
|
||||||
|
row.mail_address,
|
||||||
|
row.email,
|
||||||
|
row.recipient,
|
||||||
|
]));
|
||||||
|
const raw = firstNonEmptyString([row.raw, row.source, row.mime, row.message]);
|
||||||
|
const parsedMime = raw ? extractTextFromMime(raw) : { headers: {}, text: '' };
|
||||||
|
const subject = decodeMimeEncodedWords(firstNonEmptyString([
|
||||||
|
row.subject,
|
||||||
|
parsedMime.headers.subject,
|
||||||
|
]));
|
||||||
|
const fromAddress = decodeMimeEncodedWords(firstNonEmptyString([
|
||||||
|
row.from,
|
||||||
|
row.sender,
|
||||||
|
row.mail_from,
|
||||||
|
parsedMime.headers.from,
|
||||||
|
]));
|
||||||
|
const bodyPreview = firstNonEmptyString([
|
||||||
|
row.text,
|
||||||
|
row.preview,
|
||||||
|
row.body,
|
||||||
|
parsedMime.text,
|
||||||
|
raw,
|
||||||
|
]).replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: firstNonEmptyString([row.id, row.mail_id]),
|
||||||
|
address,
|
||||||
|
addressId: firstNonEmptyString([row.address_id, row.addressId]),
|
||||||
|
subject,
|
||||||
|
from: {
|
||||||
|
emailAddress: {
|
||||||
|
address: fromAddress,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bodyPreview,
|
||||||
|
raw,
|
||||||
|
receivedDateTime: normalizeReceivedDateTime(firstNonEmptyString([
|
||||||
|
row.receivedDateTime,
|
||||||
|
row.received_at,
|
||||||
|
row.created_at,
|
||||||
|
row.createdAt,
|
||||||
|
row.updated_at,
|
||||||
|
row.date,
|
||||||
|
])),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCloudflareTempEmailMailApiMessages(payload) {
|
||||||
|
return getCloudflareTempEmailMailRows(payload)
|
||||||
|
.map((row) => normalizeCloudflareTempEmailMessage(row))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCloudflareTempEmailAddressFromResponse(payload = {}) {
|
||||||
|
return firstNonEmptyString([
|
||||||
|
payload.address,
|
||||||
|
payload.email,
|
||||||
|
payload?.data?.address,
|
||||||
|
payload?.data?.email,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
DEFAULT_MAIL_PAGE_SIZE,
|
||||||
|
buildCloudflareTempEmailHeaders,
|
||||||
|
getCloudflareTempEmailAddressFromResponse,
|
||||||
|
joinCloudflareTempEmailUrl,
|
||||||
|
normalizeCloudflareTempEmailAddress,
|
||||||
|
normalizeCloudflareTempEmailBaseUrl,
|
||||||
|
normalizeCloudflareTempEmailDomain,
|
||||||
|
normalizeCloudflareTempEmailDomains,
|
||||||
|
normalizeCloudflareTempEmailMailApiMessages,
|
||||||
|
normalizeCloudflareTempEmailMessage,
|
||||||
|
};
|
||||||
|
});
|
||||||
+567
-144
@@ -11,7 +11,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|||||||
|| message.type === 'STEP8_FIND_AND_CLICK'
|
|| message.type === 'STEP8_FIND_AND_CLICK'
|
||||||
|| message.type === 'STEP8_GET_STATE'
|
|| message.type === 'STEP8_GET_STATE'
|
||||||
|| message.type === 'STEP8_TRIGGER_CONTINUE'
|
|| message.type === 'STEP8_TRIGGER_CONTINUE'
|
||||||
|| message.type === 'PREPARE_LOGIN_CODE'
|
|| message.type === 'GET_LOGIN_AUTH_STATE'
|
||||||
|| message.type === 'PREPARE_SIGNUP_VERIFICATION'
|
|| message.type === 'PREPARE_SIGNUP_VERIFICATION'
|
||||||
|| message.type === 'RESEND_VERIFICATION_CODE'
|
|| message.type === 'RESEND_VERIFICATION_CODE'
|
||||||
) {
|
) {
|
||||||
@@ -52,10 +52,10 @@ async function handleCommand(message) {
|
|||||||
case 'FILL_CODE':
|
case 'FILL_CODE':
|
||||||
// Step 4 = signup code, Step 7 = login code (same handler)
|
// Step 4 = signup code, Step 7 = login code (same handler)
|
||||||
return await fillVerificationCode(message.step, message.payload);
|
return await fillVerificationCode(message.step, message.payload);
|
||||||
|
case 'GET_LOGIN_AUTH_STATE':
|
||||||
|
return serializeLoginAuthState(inspectLoginAuthState());
|
||||||
case 'PREPARE_SIGNUP_VERIFICATION':
|
case 'PREPARE_SIGNUP_VERIFICATION':
|
||||||
return await prepareSignupVerificationFlow(message.payload);
|
return await prepareSignupVerificationFlow(message.payload);
|
||||||
case 'PREPARE_LOGIN_CODE':
|
|
||||||
return await prepareLoginCodeFlow();
|
|
||||||
case 'RESEND_VERIFICATION_CODE':
|
case 'RESEND_VERIFICATION_CODE':
|
||||||
return await resendVerificationCode(message.step);
|
return await resendVerificationCode(message.step);
|
||||||
case 'STEP8_FIND_AND_CLICK':
|
case 'STEP8_FIND_AND_CLICK':
|
||||||
@@ -176,86 +176,9 @@ function isEmailVerificationPage() {
|
|||||||
return /\/email-verification(?:[/?#]|$)/i.test(location.pathname || '');
|
return /\/email-verification(?:[/?#]|$)/i.test(location.pathname || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function prepareLoginCodeFlow(timeout = 15000) {
|
|
||||||
const readyTarget = getVerificationCodeTarget();
|
|
||||||
if (readyTarget) {
|
|
||||||
log('步骤 7:验证码输入框已就绪。');
|
|
||||||
return { ready: true, mode: readyTarget.type };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEmailVerificationPage() && isVerificationPageStillVisible()) {
|
|
||||||
log('步骤 7:已进入邮箱验证码页面,正在等待验证码输入框或重发入口稳定。');
|
|
||||||
return { ready: true, mode: 'verification_page' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialRestartSignal = getStep7RestartFromStep6Signal();
|
|
||||||
if (initialRestartSignal) {
|
|
||||||
log('步骤 7:检测到登录页超时报错,准备回到步骤 6 重新发起登录验证码流程...', 'warn');
|
|
||||||
return initialRestartSignal;
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = Date.now();
|
|
||||||
let switchClickCount = 0;
|
|
||||||
let lastSwitchAttemptAt = 0;
|
|
||||||
let loggedPasswordPage = false;
|
|
||||||
let loggedVerificationPage = false;
|
|
||||||
|
|
||||||
while (Date.now() - start < timeout) {
|
|
||||||
throwIfStopped();
|
|
||||||
|
|
||||||
const target = getVerificationCodeTarget();
|
|
||||||
if (target) {
|
|
||||||
log('步骤 7:验证码页面已就绪。');
|
|
||||||
return { ready: true, mode: target.type };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isEmailVerificationPage() && isVerificationPageStillVisible()) {
|
|
||||||
if (!loggedVerificationPage) {
|
|
||||||
loggedVerificationPage = true;
|
|
||||||
log('步骤 7:页面已进入邮箱验证码流程,继续等待验证码输入框渲染...');
|
|
||||||
}
|
|
||||||
await sleep(250);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const restartSignal = getStep7RestartFromStep6Signal();
|
|
||||||
if (restartSignal) {
|
|
||||||
log('步骤 7:检测到登录页超时报错,准备回到步骤 6 重新发起登录验证码流程...', 'warn');
|
|
||||||
return restartSignal;
|
|
||||||
}
|
|
||||||
|
|
||||||
const passwordInput = document.querySelector('input[type="password"]');
|
|
||||||
const switchTrigger = findOneTimeCodeLoginTrigger();
|
|
||||||
|
|
||||||
if (switchTrigger && (switchClickCount === 0 || Date.now() - lastSwitchAttemptAt > 1500)) {
|
|
||||||
switchClickCount += 1;
|
|
||||||
lastSwitchAttemptAt = Date.now();
|
|
||||||
loggedPasswordPage = false;
|
|
||||||
log('步骤 7:检测到密码页,正在切换到一次性验证码登录...');
|
|
||||||
await humanPause(350, 900);
|
|
||||||
const verificationRequestedAt = Date.now();
|
|
||||||
simulateClick(switchTrigger);
|
|
||||||
await sleep(1200);
|
|
||||||
return { ready: true, mode: 'verification_switch', verificationRequestedAt };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (passwordInput && !loggedPasswordPage) {
|
|
||||||
loggedPasswordPage = true;
|
|
||||||
log('步骤 7:正在等待密码页上的一次性验证码登录入口...');
|
|
||||||
}
|
|
||||||
|
|
||||||
await sleep(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('无法切换到一次性验证码验证页面。URL: ' + location.href);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resendVerificationCode(step, timeout = 45000) {
|
async function resendVerificationCode(step, timeout = 45000) {
|
||||||
if (step === 7) {
|
if (step === 7) {
|
||||||
const prepareResult = await prepareLoginCodeFlow();
|
await waitForLoginVerificationPageReady();
|
||||||
if (prepareResult?.restartFromStep6) {
|
|
||||||
return prepareResult;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
@@ -730,26 +653,248 @@ function getLoginTimeoutErrorPageState() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSignupPasswordErrorPage() {
|
function getLoginEmailInput() {
|
||||||
return Boolean(getSignupPasswordTimeoutErrorPageState());
|
const input = document.querySelector(
|
||||||
|
'input[type="email"], input[name="email"], input[name="username"], input[id*="email"], input[placeholder*="email" i], input[placeholder*="Email"]'
|
||||||
|
);
|
||||||
|
return input && isVisibleElement(input) ? input : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildStep7RestartFromStep6Marker(reason, url = location.href) {
|
function getLoginPasswordInput() {
|
||||||
return `STEP7_RESTART_FROM_STEP6::${reason || 'unknown'}::${url || ''}`;
|
const input = document.querySelector('input[type="password"]');
|
||||||
|
return input && isVisibleElement(input) ? input : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStep7RestartFromStep6Signal() {
|
function getLoginSubmitButton({ allowDisabled = false } = {}) {
|
||||||
const timeoutPage = getLoginTimeoutErrorPageState();
|
const direct = document.querySelector('button[type="submit"], input[type="submit"]');
|
||||||
if (!timeoutPage) {
|
if (direct && isVisibleElement(direct) && (allowDisabled || isActionEnabled(direct))) {
|
||||||
return null;
|
return direct;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const candidates = document.querySelectorAll(
|
||||||
error: buildStep7RestartFromStep6Marker('login_timeout_error_page', timeoutPage.url),
|
'button, a, [role="button"], [role="link"], input[type="button"], input[type="submit"]'
|
||||||
restartFromStep6: true,
|
);
|
||||||
reason: 'login_timeout_error_page',
|
return Array.from(candidates).find((el) => {
|
||||||
url: timeoutPage.url,
|
if (!isVisibleElement(el) || (!allowDisabled && !isActionEnabled(el))) return false;
|
||||||
|
const text = getActionText(el);
|
||||||
|
if (!text || ONE_TIME_CODE_LOGIN_PATTERN.test(text)) return false;
|
||||||
|
return /continue|next|submit|sign\s*in|log\s*in|继续|下一步|登录/i.test(text);
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectLoginAuthState() {
|
||||||
|
const retryState = getLoginTimeoutErrorPageState();
|
||||||
|
const verificationTarget = getVerificationCodeTarget();
|
||||||
|
const passwordInput = getLoginPasswordInput();
|
||||||
|
const emailInput = getLoginEmailInput();
|
||||||
|
const switchTrigger = findOneTimeCodeLoginTrigger();
|
||||||
|
const submitButton = getLoginSubmitButton({ allowDisabled: true });
|
||||||
|
const verificationVisible = isVerificationPageStillVisible();
|
||||||
|
const addPhonePage = isAddPhonePageReady();
|
||||||
|
const consentReady = isStep8Ready();
|
||||||
|
const oauthConsentPage = isOAuthConsentPage();
|
||||||
|
const baseState = {
|
||||||
|
state: 'unknown',
|
||||||
|
url: location.href,
|
||||||
|
path: location.pathname || '',
|
||||||
|
retryButton: retryState?.retryButton || null,
|
||||||
|
retryEnabled: Boolean(retryState?.retryEnabled),
|
||||||
|
titleMatched: Boolean(retryState?.titleMatched),
|
||||||
|
detailMatched: Boolean(retryState?.detailMatched),
|
||||||
|
verificationTarget,
|
||||||
|
passwordInput,
|
||||||
|
emailInput,
|
||||||
|
submitButton,
|
||||||
|
switchTrigger,
|
||||||
|
verificationVisible,
|
||||||
|
addPhonePage,
|
||||||
|
oauthConsentPage,
|
||||||
|
consentReady,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (verificationTarget || verificationVisible) {
|
||||||
|
return {
|
||||||
|
...baseState,
|
||||||
|
state: 'verification_page',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (retryState) {
|
||||||
|
return {
|
||||||
|
...baseState,
|
||||||
|
state: 'login_timeout_error_page',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addPhonePage) {
|
||||||
|
return {
|
||||||
|
...baseState,
|
||||||
|
state: 'add_phone_page',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oauthConsentPage) {
|
||||||
|
return {
|
||||||
|
...baseState,
|
||||||
|
state: 'oauth_consent_page',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passwordInput || switchTrigger) {
|
||||||
|
return {
|
||||||
|
...baseState,
|
||||||
|
state: 'password_page',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emailInput) {
|
||||||
|
return {
|
||||||
|
...baseState,
|
||||||
|
state: 'email_page',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeLoginAuthState(snapshot) {
|
||||||
|
return {
|
||||||
|
state: snapshot?.state || 'unknown',
|
||||||
|
url: snapshot?.url || location.href,
|
||||||
|
path: snapshot?.path || location.pathname || '',
|
||||||
|
retryEnabled: Boolean(snapshot?.retryEnabled),
|
||||||
|
titleMatched: Boolean(snapshot?.titleMatched),
|
||||||
|
detailMatched: Boolean(snapshot?.detailMatched),
|
||||||
|
hasVerificationTarget: Boolean(snapshot?.verificationTarget),
|
||||||
|
hasPasswordInput: Boolean(snapshot?.passwordInput),
|
||||||
|
hasEmailInput: Boolean(snapshot?.emailInput),
|
||||||
|
hasSubmitButton: Boolean(snapshot?.submitButton),
|
||||||
|
hasSwitchTrigger: Boolean(snapshot?.switchTrigger),
|
||||||
|
verificationVisible: Boolean(snapshot?.verificationVisible),
|
||||||
|
addPhonePage: Boolean(snapshot?.addPhonePage),
|
||||||
|
oauthConsentPage: Boolean(snapshot?.oauthConsentPage),
|
||||||
|
consentReady: Boolean(snapshot?.consentReady),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLoginAuthStateLabel(snapshot) {
|
||||||
|
switch (snapshot?.state) {
|
||||||
|
case 'verification_page':
|
||||||
|
return '登录验证码页';
|
||||||
|
case 'password_page':
|
||||||
|
return '密码页';
|
||||||
|
case 'email_page':
|
||||||
|
return '邮箱输入页';
|
||||||
|
case 'login_timeout_error_page':
|
||||||
|
return '登录超时报错页';
|
||||||
|
case 'oauth_consent_page':
|
||||||
|
return 'OAuth 授权页';
|
||||||
|
case 'add_phone_page':
|
||||||
|
return '手机号页';
|
||||||
|
default:
|
||||||
|
return '未知页面';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForKnownLoginAuthState(timeout = 15000) {
|
||||||
|
const start = Date.now();
|
||||||
|
let snapshot = inspectLoginAuthState();
|
||||||
|
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
throwIfStopped();
|
||||||
|
snapshot = inspectLoginAuthState();
|
||||||
|
if (snapshot.state !== 'unknown') {
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
await sleep(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForLoginVerificationPageReady(timeout = 10000) {
|
||||||
|
const start = Date.now();
|
||||||
|
let snapshot = inspectLoginAuthState();
|
||||||
|
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
throwIfStopped();
|
||||||
|
snapshot = inspectLoginAuthState();
|
||||||
|
if (snapshot.state === 'verification_page') {
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
if (snapshot.state !== 'unknown') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await sleep(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`当前未进入登录验证码页面,请先重新完成步骤 6。当前状态:${getLoginAuthStateLabel(snapshot)}。URL: ${snapshot?.url || location.href}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStep6SuccessResult(snapshot, options = {}) {
|
||||||
|
return {
|
||||||
|
step6Outcome: 'success',
|
||||||
|
state: snapshot?.state || 'verification_page',
|
||||||
|
url: snapshot?.url || location.href,
|
||||||
|
via: options.via || '',
|
||||||
|
loginVerificationRequestedAt: options.loginVerificationRequestedAt || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStep6RecoverableResult(reason, snapshot, options = {}) {
|
||||||
|
return {
|
||||||
|
step6Outcome: 'recoverable',
|
||||||
|
reason,
|
||||||
|
state: snapshot?.state || 'unknown',
|
||||||
|
url: snapshot?.url || location.href,
|
||||||
|
message: options.message || '',
|
||||||
|
loginVerificationRequestedAt: options.loginVerificationRequestedAt || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwForStep6FatalState(snapshot) {
|
||||||
|
switch (snapshot?.state) {
|
||||||
|
case 'oauth_consent_page':
|
||||||
|
throw new Error(`当前页面已进入 OAuth 授权页,未经过登录验证码页,无法完成步骤 6。URL: ${snapshot.url}`);
|
||||||
|
case 'add_phone_page':
|
||||||
|
throw new Error(`当前页面已进入手机号页面,未经过登录验证码页,无法完成步骤 6。URL: ${snapshot.url}`);
|
||||||
|
case 'unknown':
|
||||||
|
throw new Error(`无法识别当前登录页面状态。URL: ${snapshot?.url || location.href}`);
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function triggerLoginSubmitAction(button, fallbackField) {
|
||||||
|
const form = button?.form || fallbackField?.form || button?.closest?.('form') || fallbackField?.closest?.('form') || null;
|
||||||
|
|
||||||
|
await humanPause(400, 1100);
|
||||||
|
if (button && isActionEnabled(button)) {
|
||||||
|
simulateClick(button);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form && typeof form.requestSubmit === 'function') {
|
||||||
|
if (button && button.form === form) {
|
||||||
|
form.requestSubmit(button);
|
||||||
|
} else {
|
||||||
|
form.requestSubmit();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (button && typeof button.click === 'function') {
|
||||||
|
button.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('未找到可用的登录提交按钮。URL: ' + location.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSignupPasswordErrorPage() {
|
||||||
|
return Boolean(getSignupPasswordTimeoutErrorPageState());
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSignupEmailAlreadyExistsPage() {
|
function isSignupEmailAlreadyExistsPage() {
|
||||||
@@ -922,10 +1067,7 @@ async function fillVerificationCode(step, payload) {
|
|||||||
log(`步骤 ${step}:正在填写验证码:${code}`);
|
log(`步骤 ${step}:正在填写验证码:${code}`);
|
||||||
|
|
||||||
if (step === 7) {
|
if (step === 7) {
|
||||||
const prepareResult = await prepareLoginCodeFlow();
|
await waitForLoginVerificationPageReady();
|
||||||
if (prepareResult?.restartFromStep6) {
|
|
||||||
return prepareResult;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find code input — could be a single input or multiple separate inputs
|
// Find code input — could be a single input or multiple separate inputs
|
||||||
@@ -986,63 +1128,344 @@ async function fillVerificationCode(step, payload) {
|
|||||||
// Step 6: Login with registered account (on OAuth auth page)
|
// Step 6: Login with registered account (on OAuth auth page)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
async function waitForStep6EmailSubmitTransition(emailSubmittedAt, timeout = 12000) {
|
||||||
|
const start = Date.now();
|
||||||
|
let snapshot = inspectLoginAuthState();
|
||||||
|
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
throwIfStopped();
|
||||||
|
snapshot = inspectLoginAuthState();
|
||||||
|
|
||||||
|
if (snapshot.state === 'verification_page') {
|
||||||
|
return {
|
||||||
|
action: 'done',
|
||||||
|
result: createStep6SuccessResult(snapshot, {
|
||||||
|
via: 'email_submit',
|
||||||
|
loginVerificationRequestedAt: emailSubmittedAt,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.state === 'password_page') {
|
||||||
|
return { action: 'password', snapshot };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.state === 'login_timeout_error_page') {
|
||||||
|
return {
|
||||||
|
action: 'recoverable',
|
||||||
|
result: createStep6RecoverableResult('login_timeout_error_page', snapshot, {
|
||||||
|
message: '提交邮箱后进入登录超时报错页。',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.state === 'oauth_consent_page') {
|
||||||
|
throw new Error(`提交邮箱后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.state === 'add_phone_page') {
|
||||||
|
throw new Error(`提交邮箱后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(250);
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot = inspectLoginAuthState();
|
||||||
|
if (snapshot.state === 'verification_page') {
|
||||||
|
return {
|
||||||
|
action: 'done',
|
||||||
|
result: createStep6SuccessResult(snapshot, {
|
||||||
|
via: 'email_submit',
|
||||||
|
loginVerificationRequestedAt: emailSubmittedAt,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (snapshot.state === 'password_page') {
|
||||||
|
return { action: 'password', snapshot };
|
||||||
|
}
|
||||||
|
if (snapshot.state === 'login_timeout_error_page') {
|
||||||
|
return {
|
||||||
|
action: 'recoverable',
|
||||||
|
result: createStep6RecoverableResult('login_timeout_error_page', snapshot, {
|
||||||
|
message: '提交邮箱后进入登录超时报错页。',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (snapshot.state === 'oauth_consent_page') {
|
||||||
|
throw new Error(`提交邮箱后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`);
|
||||||
|
}
|
||||||
|
if (snapshot.state === 'add_phone_page') {
|
||||||
|
throw new Error(`提交邮箱后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: 'recoverable',
|
||||||
|
result: createStep6RecoverableResult('email_submit_stalled', snapshot, {
|
||||||
|
message: '提交邮箱后长时间未进入密码页或登录验证码页。',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForStep6PasswordSubmitTransition(passwordSubmittedAt, timeout = 10000) {
|
||||||
|
const start = Date.now();
|
||||||
|
let snapshot = inspectLoginAuthState();
|
||||||
|
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
throwIfStopped();
|
||||||
|
snapshot = inspectLoginAuthState();
|
||||||
|
|
||||||
|
if (snapshot.state === 'verification_page') {
|
||||||
|
return {
|
||||||
|
action: 'done',
|
||||||
|
result: createStep6SuccessResult(snapshot, {
|
||||||
|
via: 'password_submit',
|
||||||
|
loginVerificationRequestedAt: passwordSubmittedAt,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.state === 'login_timeout_error_page') {
|
||||||
|
return {
|
||||||
|
action: 'recoverable',
|
||||||
|
result: createStep6RecoverableResult('login_timeout_error_page', snapshot, {
|
||||||
|
message: '提交密码后进入登录超时报错页。',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.state === 'oauth_consent_page') {
|
||||||
|
throw new Error(`提交密码后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.state === 'add_phone_page') {
|
||||||
|
throw new Error(`提交密码后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(250);
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot = inspectLoginAuthState();
|
||||||
|
if (snapshot.state === 'verification_page') {
|
||||||
|
return {
|
||||||
|
action: 'done',
|
||||||
|
result: createStep6SuccessResult(snapshot, {
|
||||||
|
via: 'password_submit',
|
||||||
|
loginVerificationRequestedAt: passwordSubmittedAt,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (snapshot.state === 'login_timeout_error_page') {
|
||||||
|
return {
|
||||||
|
action: 'recoverable',
|
||||||
|
result: createStep6RecoverableResult('login_timeout_error_page', snapshot, {
|
||||||
|
message: '提交密码后进入登录超时报错页。',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (snapshot.state === 'oauth_consent_page') {
|
||||||
|
throw new Error(`提交密码后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`);
|
||||||
|
}
|
||||||
|
if (snapshot.state === 'add_phone_page') {
|
||||||
|
throw new Error(`提交密码后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`);
|
||||||
|
}
|
||||||
|
if (snapshot.state === 'password_page' && snapshot.switchTrigger) {
|
||||||
|
return { action: 'switch', snapshot };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: 'recoverable',
|
||||||
|
result: createStep6RecoverableResult('password_submit_stalled', snapshot, {
|
||||||
|
message: '提交密码后仍未进入登录验证码页。',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForStep6SwitchTransition(loginVerificationRequestedAt, timeout = 10000) {
|
||||||
|
const start = Date.now();
|
||||||
|
let snapshot = inspectLoginAuthState();
|
||||||
|
|
||||||
|
while (Date.now() - start < timeout) {
|
||||||
|
throwIfStopped();
|
||||||
|
snapshot = inspectLoginAuthState();
|
||||||
|
|
||||||
|
if (snapshot.state === 'verification_page') {
|
||||||
|
return createStep6SuccessResult(snapshot, {
|
||||||
|
via: 'switch_to_one_time_code_login',
|
||||||
|
loginVerificationRequestedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.state === 'login_timeout_error_page') {
|
||||||
|
return createStep6RecoverableResult('login_timeout_error_page', snapshot, {
|
||||||
|
message: '切换到一次性验证码登录后进入登录超时报错页。',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.state === 'oauth_consent_page') {
|
||||||
|
throw new Error(`切换到一次性验证码登录后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.state === 'add_phone_page') {
|
||||||
|
throw new Error(`切换到一次性验证码登录后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(250);
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot = inspectLoginAuthState();
|
||||||
|
if (snapshot.state === 'verification_page') {
|
||||||
|
return createStep6SuccessResult(snapshot, {
|
||||||
|
via: 'switch_to_one_time_code_login',
|
||||||
|
loginVerificationRequestedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (snapshot.state === 'login_timeout_error_page') {
|
||||||
|
return createStep6RecoverableResult('login_timeout_error_page', snapshot, {
|
||||||
|
message: '切换到一次性验证码登录后进入登录超时报错页。',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (snapshot.state === 'oauth_consent_page') {
|
||||||
|
throw new Error(`切换到一次性验证码登录后页面直接进入 OAuth 授权页,未经过登录验证码页。URL: ${snapshot.url}`);
|
||||||
|
}
|
||||||
|
if (snapshot.state === 'add_phone_page') {
|
||||||
|
throw new Error(`切换到一次性验证码登录后页面直接进入手机号页面,未经过登录验证码页。URL: ${snapshot.url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createStep6RecoverableResult('one_time_code_switch_stalled', snapshot, {
|
||||||
|
message: '点击一次性验证码登录后仍未进入登录验证码页。',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function step6SwitchToOneTimeCodeLogin(snapshot) {
|
||||||
|
const switchTrigger = snapshot?.switchTrigger || findOneTimeCodeLoginTrigger();
|
||||||
|
if (!switchTrigger || !isActionEnabled(switchTrigger)) {
|
||||||
|
return createStep6RecoverableResult('missing_one_time_code_trigger', inspectLoginAuthState(), {
|
||||||
|
message: '当前登录页没有可用的一次性验证码登录入口。',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
log('步骤 6:已检测到一次性验证码登录入口,准备切换...');
|
||||||
|
const loginVerificationRequestedAt = Date.now();
|
||||||
|
await humanPause(350, 900);
|
||||||
|
simulateClick(switchTrigger);
|
||||||
|
log('步骤 6:已点击一次性验证码登录');
|
||||||
|
await sleep(1200);
|
||||||
|
return waitForStep6SwitchTransition(loginVerificationRequestedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function step6LoginFromPasswordPage(payload, snapshot) {
|
||||||
|
const currentSnapshot = snapshot || inspectLoginAuthState();
|
||||||
|
|
||||||
|
if (currentSnapshot.passwordInput) {
|
||||||
|
if (!payload.password) {
|
||||||
|
throw new Error('登录时缺少密码,步骤 6 无法继续。');
|
||||||
|
}
|
||||||
|
|
||||||
|
log('步骤 6:已进入密码页,准备填写密码...');
|
||||||
|
await humanPause(550, 1450);
|
||||||
|
fillInput(currentSnapshot.passwordInput, payload.password);
|
||||||
|
log('步骤 6:已填写密码');
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
const passwordSubmittedAt = Date.now();
|
||||||
|
await triggerLoginSubmitAction(currentSnapshot.submitButton, currentSnapshot.passwordInput);
|
||||||
|
log('步骤 6:已提交密码');
|
||||||
|
|
||||||
|
const transition = await waitForStep6PasswordSubmitTransition(passwordSubmittedAt);
|
||||||
|
if (transition.action === 'done') {
|
||||||
|
log('步骤 6:已进入登录验证码页面。', 'ok');
|
||||||
|
return transition.result;
|
||||||
|
}
|
||||||
|
if (transition.action === 'recoverable') {
|
||||||
|
log(`步骤 6:${transition.result.message || '提交密码后仍未进入登录验证码页面,准备重新执行步骤 6。'}`, 'warn');
|
||||||
|
return transition.result;
|
||||||
|
}
|
||||||
|
if (transition.action === 'switch') {
|
||||||
|
return step6SwitchToOneTimeCodeLogin(transition.snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createStep6RecoverableResult('password_submit_unknown', inspectLoginAuthState(), {
|
||||||
|
message: '提交密码后未得到可用的下一步状态。',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSnapshot.switchTrigger) {
|
||||||
|
return step6SwitchToOneTimeCodeLogin(currentSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createStep6RecoverableResult('password_page_unactionable', currentSnapshot, {
|
||||||
|
message: '当前停留在登录页,但没有可提交密码的输入框,也没有一次性验证码登录入口。',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function step6LoginFromEmailPage(payload, snapshot) {
|
||||||
|
const currentSnapshot = snapshot || inspectLoginAuthState();
|
||||||
|
const emailInput = currentSnapshot.emailInput || getLoginEmailInput();
|
||||||
|
if (!emailInput) {
|
||||||
|
throw new Error('在登录页未找到邮箱输入框。URL: ' + location.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((emailInput.value || '').trim() !== payload.email) {
|
||||||
|
await humanPause(500, 1400);
|
||||||
|
fillInput(emailInput, payload.email);
|
||||||
|
log('步骤 6:已填写邮箱');
|
||||||
|
} else {
|
||||||
|
log('步骤 6:邮箱已在输入框中,准备提交...');
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(500);
|
||||||
|
const emailSubmittedAt = Date.now();
|
||||||
|
await triggerLoginSubmitAction(currentSnapshot.submitButton, emailInput);
|
||||||
|
log('步骤 6:已提交邮箱');
|
||||||
|
|
||||||
|
const transition = await waitForStep6EmailSubmitTransition(emailSubmittedAt);
|
||||||
|
if (transition.action === 'done') {
|
||||||
|
log('步骤 6:已进入登录验证码页面。', 'ok');
|
||||||
|
return transition.result;
|
||||||
|
}
|
||||||
|
if (transition.action === 'recoverable') {
|
||||||
|
log(`步骤 6:${transition.result.message || '提交邮箱后仍未进入目标页面,准备重新执行步骤 6。'}`, 'warn');
|
||||||
|
return transition.result;
|
||||||
|
}
|
||||||
|
if (transition.action === 'password') {
|
||||||
|
return step6LoginFromPasswordPage(payload, transition.snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createStep6RecoverableResult('email_submit_unknown', inspectLoginAuthState(), {
|
||||||
|
message: '提交邮箱后未得到可用的下一步状态。',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function step6_login(payload) {
|
async function step6_login(payload) {
|
||||||
const { email, password } = payload;
|
const { email } = payload;
|
||||||
if (!email) throw new Error('登录时缺少邮箱地址。');
|
if (!email) throw new Error('登录时缺少邮箱地址。');
|
||||||
|
|
||||||
log(`步骤 6:正在使用 ${email} 登录...`);
|
log(`步骤 6:正在使用 ${email} 登录...`);
|
||||||
|
|
||||||
// Wait for email input on the auth page
|
const snapshot = await waitForKnownLoginAuthState(15000);
|
||||||
let emailInput = null;
|
|
||||||
try {
|
if (snapshot.state === 'verification_page') {
|
||||||
emailInput = await waitForElement(
|
log('步骤 6:登录验证码页面已就绪。', 'ok');
|
||||||
'input[type="email"], input[name="email"], input[name="username"], input[id*="email"], input[placeholder*="email" i], input[placeholder*="Email"]',
|
return createStep6SuccessResult(snapshot, { via: 'already_on_verification_page' });
|
||||||
15000
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
throw new Error('在登录页未找到邮箱输入框。URL: ' + location.href);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await humanPause(500, 1400);
|
if (snapshot.state === 'login_timeout_error_page') {
|
||||||
fillInput(emailInput, email);
|
log('步骤 6:检测到登录超时报错,准备重新执行步骤 6。', 'warn');
|
||||||
log('步骤 6:邮箱已填写');
|
return createStep6RecoverableResult('login_timeout_error_page', snapshot, {
|
||||||
|
message: '当前页面处于登录超时报错页。',
|
||||||
// Submit email
|
});
|
||||||
await sleep(500);
|
|
||||||
const submitBtn1 = document.querySelector('button[type="submit"]')
|
|
||||||
|| await waitForElementByText('button', /continue|next|submit|继续|下一步/i, 5000).catch(() => null);
|
|
||||||
if (submitBtn1) {
|
|
||||||
await humanPause(400, 1100);
|
|
||||||
simulateClick(submitBtn1);
|
|
||||||
log('步骤 6:邮箱已提交');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await sleep(2000);
|
if (snapshot.state === 'email_page') {
|
||||||
|
return step6LoginFromEmailPage(payload, snapshot);
|
||||||
// Check for password field
|
|
||||||
const passwordInput = document.querySelector('input[type="password"]');
|
|
||||||
if (passwordInput) {
|
|
||||||
log('步骤 6:已找到密码输入框,正在填写密码...');
|
|
||||||
await humanPause(550, 1450);
|
|
||||||
fillInput(passwordInput, password);
|
|
||||||
|
|
||||||
await sleep(500);
|
|
||||||
const submitBtn2 = document.querySelector('button[type="submit"]')
|
|
||||||
|| await waitForElementByText('button', /continue|log\s*in|submit|sign\s*in|登录|继续/i, 5000).catch(() => null);
|
|
||||||
// Report complete BEFORE submit in case page navigates
|
|
||||||
reportComplete(6, { needsOTP: true });
|
|
||||||
|
|
||||||
if (submitBtn2) {
|
|
||||||
await humanPause(450, 1200);
|
|
||||||
simulateClick(submitBtn2);
|
|
||||||
log('步骤 6:密码已提交,可能还需要验证码(步骤 7)');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No password field — OTP flow
|
if (snapshot.state === 'password_page') {
|
||||||
log('步骤 6:未发现密码输入框,可能进入验证码流程或自动跳转。');
|
return step6LoginFromPasswordPage(payload, snapshot);
|
||||||
reportComplete(6, { needsOTP: true });
|
}
|
||||||
|
|
||||||
|
throwForStep6FatalState(snapshot);
|
||||||
|
throw new Error(`无法识别当前登录页面状态。URL: ${snapshot?.url || location.href}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -603,25 +603,26 @@ async function step9_vpsVerify(payload) {
|
|||||||
fillInput(urlInput, localhostUrl);
|
fillInput(urlInput, localhostUrl);
|
||||||
log(`步骤 9:已填写回调地址:${localhostUrl.slice(0, 80)}...`);
|
log(`步骤 9:已填写回调地址:${localhostUrl.slice(0, 80)}...`);
|
||||||
|
|
||||||
// Find and click "提交回调 URL" button
|
// Find and click the callback submit button in supported UI languages.
|
||||||
|
const callbackSubmitPattern = /提交回调\s*URL|Submit\s+Callback\s+URL|Отправить\s+Callback\s+URL/i;
|
||||||
let submitBtn = null;
|
let submitBtn = null;
|
||||||
try {
|
try {
|
||||||
submitBtn = await waitForElementByText(
|
submitBtn = await waitForElementByText(
|
||||||
'[class*="callbackActions"] button, [class*="callbackSection"] button',
|
'[class*="callbackActions"] button, [class*="callbackSection"] button',
|
||||||
/提交/,
|
callbackSubmitPattern,
|
||||||
5000
|
5000
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
submitBtn = await waitForElementByText('button.btn', /提交回调/, 5000);
|
submitBtn = await waitForElementByText('button.btn', callbackSubmitPattern, 5000);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('未找到“提交回调 URL”按钮。URL: ' + location.href);
|
throw new Error('未找到回调提交按钮(提交回调 URL / Submit Callback URL / Отправить Callback URL)。URL: ' + location.href);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await humanPause(450, 1200);
|
await humanPause(450, 1200);
|
||||||
simulateClick(submitBtn);
|
simulateClick(submitBtn);
|
||||||
log('步骤 9:已点击“提交回调 URL”,正在等待认证结果...');
|
log('步骤 9:已点击回调提交按钮,正在等待认证结果...');
|
||||||
|
|
||||||
const verifiedStatus = await waitForExactSuccessBadge();
|
const verifiedStatus = await waitForExactSuccessBadge();
|
||||||
log(`步骤 9:${verifiedStatus}`, 'ok');
|
log(`步骤 9:${verifiedStatus}`, 'ok');
|
||||||
|
|||||||
@@ -1,400 +0,0 @@
|
|||||||
# AI 版本发布标准流程
|
|
||||||
|
|
||||||
## 用途
|
|
||||||
|
|
||||||
本文用于给 AI 一个固定、完整、可重复执行的版本发布流程。
|
|
||||||
|
|
||||||
当用户在本仓库中引用本文,并明确说出类似下面的话时:
|
|
||||||
|
|
||||||
- `更新版本到 6.5.0`
|
|
||||||
- `按本文发布 6.5.0`
|
|
||||||
- `引用这个文件,把版本更新到 6.5.0 并发版`
|
|
||||||
|
|
||||||
AI 必须按本文执行,不能猜流程,不能跳步骤,不能只做一半。
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
AI 在收到“更新版本到 x.x.x”后,必须完成下面两段流程。
|
|
||||||
|
|
||||||
1. 发布前流程。
|
|
||||||
修改 `manifest.json` 中的版本号。
|
|
||||||
提交本次准备发布的代码。
|
|
||||||
基于“上一个 GitHub Release 到当前 HEAD”的真实差异分析更新内容。
|
|
||||||
把发布文案先返回给用户看,且必须放在代码块里。
|
|
||||||
|
|
||||||
2. 用户确认后的发布流程。
|
|
||||||
把用户确认过的文案作为 GitHub Release 正文发布。
|
|
||||||
发布完成后,再次从 GitHub 线上读取正文。
|
|
||||||
审查正文是否乱码、是否异常、是否与用户确认稿一致。
|
|
||||||
如果有问题,必须立刻修正后再汇报完成。
|
|
||||||
|
|
||||||
## 仓库硬性规则
|
|
||||||
|
|
||||||
1. 任何结论都不能猜,必须基于真实命令输出、真实 diff、真实 GitHub 返回结果。
|
|
||||||
2. GitHub Releases 是版本发布真相源。分析“距离上一个版本更新了什么”时,默认以上一个正式 Release 为起点。
|
|
||||||
3. 不要先去检查 `gh` 是否存在、是否登录、是否在 PATH、是否可用,也不要先跑 `gh auth status`。直接使用固定路径命令执行。
|
|
||||||
4. `gh` 固定路径使用:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$GH = 'C:\Program Files\GitHub CLI\gh.exe'
|
|
||||||
if (-not (Test-Path $GH)) {
|
|
||||||
throw 'gh.exe fixed path not found: C:\Program Files\GitHub CLI\gh.exe'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
5. 默认 GitHub 仓库固定为:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$Repo = 'QLHazyCoder/codex-oauth-automation-extension'
|
|
||||||
```
|
|
||||||
|
|
||||||
6. 默认不编译、不跑测试。完成后提醒用户自行测试。
|
|
||||||
7. 如果当前工作区里存在无法确认是否属于本次发布的改动,必须先停下来问用户,不能猜哪些该一起发、哪些不该一起发。
|
|
||||||
8. 发布完成后,必须做一次“线上正文复检”,确认不是乱码,不是空正文,不是错误版本,不是错误标签。
|
|
||||||
|
|
||||||
## 输入要求
|
|
||||||
|
|
||||||
AI 至少要从用户输入中拿到下面这些信息:
|
|
||||||
|
|
||||||
- 目标版本号,例如 `6.5.0`
|
|
||||||
- 是否已经确认可以发布
|
|
||||||
- 如果用户有特别强调的更新点,也要纳入发布文案
|
|
||||||
|
|
||||||
如果用户没有给版本号,就不能猜。
|
|
||||||
|
|
||||||
## 固定变量模板
|
|
||||||
|
|
||||||
每次执行本文时,先统一使用下面这组变量:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$GH = 'C:\Program Files\GitHub CLI\gh.exe'
|
|
||||||
if (-not (Test-Path $GH)) {
|
|
||||||
throw 'gh.exe fixed path not found: C:\Program Files\GitHub CLI\gh.exe'
|
|
||||||
}
|
|
||||||
|
|
||||||
$Repo = 'QLHazyCoder/codex-oauth-automation-extension'
|
|
||||||
$TargetVersion = '<用户指定的版本号,但是必须按照版本标准写,v+x.x.x。比如: v6.5.0>'
|
|
||||||
$TargetTag = "v$TargetVersion"
|
|
||||||
$ManifestPath = 'manifest.json'
|
|
||||||
```
|
|
||||||
|
|
||||||
## 阶段 1:发布前检查
|
|
||||||
|
|
||||||
### 1. 真实查看当前工作区
|
|
||||||
|
|
||||||
先执行:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git status --short --branch
|
|
||||||
git remote -v
|
|
||||||
```
|
|
||||||
|
|
||||||
要求:
|
|
||||||
|
|
||||||
1. 必须看清当前分支和当前未提交改动。
|
|
||||||
2. 如果工作区中有改动,必须结合 diff 判断这些改动是不是本次要发布的真实内容。
|
|
||||||
3. 如果有无法确认归属的改动,必须先问用户,不准猜。
|
|
||||||
|
|
||||||
### 2. 读取当前版本号
|
|
||||||
|
|
||||||
执行:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$Manifest = Get-Content -Path $ManifestPath -Raw | ConvertFrom-Json
|
|
||||||
$CurrentVersion = [string]$Manifest.version
|
|
||||||
$CurrentVersion
|
|
||||||
```
|
|
||||||
|
|
||||||
要求:
|
|
||||||
|
|
||||||
1. 当前版本必须从 `manifest.json` 真实读取。
|
|
||||||
2. 目标版本必须大于当前版本,不能相等,不能更小。
|
|
||||||
3. 版本比较必须按版本号比较,不能按字符串比较。
|
|
||||||
|
|
||||||
示例校验:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
if ([version]$TargetVersion -le [version]$CurrentVersion) {
|
|
||||||
throw "Target version $TargetVersion must be greater than current version $CurrentVersion"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 检查目标版本是否已经存在
|
|
||||||
|
|
||||||
先查 GitHub Releases:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$ExistingReleaseTag = ([string](& $GH api "repos/$Repo/releases?per_page=100" --jq ".[] | select(.tag_name == `"$TargetTag`") | .tag_name")).Trim()
|
|
||||||
```
|
|
||||||
|
|
||||||
再查本地 tag:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$ExistingLocalTag = ([string](git tag --list $TargetTag)).Trim()
|
|
||||||
```
|
|
||||||
|
|
||||||
再查最新一个正式 Release:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$LatestReleaseTag = ([string](& $GH api "repos/$Repo/releases?per_page=100" --jq "[.[] | select(.draft == false and .prerelease == false)][0].tag_name")).Trim()
|
|
||||||
$LatestReleaseVersion = if ($LatestReleaseTag) { $LatestReleaseTag.TrimStart('v') } else { '' }
|
|
||||||
```
|
|
||||||
|
|
||||||
规则:
|
|
||||||
|
|
||||||
1. 只要 GitHub Release 已存在这个 tag,就必须停止,不能重复发版。
|
|
||||||
2. 如果本地 tag 已存在,但 GitHub Release 不存在,也必须停止并告知用户先确认历史状态,不能直接覆盖。
|
|
||||||
3. 如果已经存在最新正式 Release,目标版本还必须大于这个正式 Release 的版本号,不能倒发版。
|
|
||||||
|
|
||||||
示例校验:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
if ($LatestReleaseVersion -and ([version]$TargetVersion -le [version]$LatestReleaseVersion)) {
|
|
||||||
throw "Target version $TargetVersion must be greater than latest released version $LatestReleaseVersion"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 阶段 2:更新版本号并提交
|
|
||||||
|
|
||||||
### 1. 修改 `manifest.json`
|
|
||||||
|
|
||||||
AI 必须只把 `manifest.json` 中的 `version` 更新为目标版本。
|
|
||||||
|
|
||||||
### 2. 检查变更内容
|
|
||||||
|
|
||||||
执行:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git diff --stat
|
|
||||||
git diff -- manifest.json
|
|
||||||
```
|
|
||||||
|
|
||||||
如果工作区里还有其他变更,也要继续真实查看它们的 diff,确认是否属于本次发布内容。
|
|
||||||
|
|
||||||
### 3. 提交本次发布
|
|
||||||
|
|
||||||
规则:
|
|
||||||
|
|
||||||
1. 如果当前工作区改动就是本次要发布的功能改动,就应与版本号一起提交。
|
|
||||||
2. 如果存在来源不明或明显无关的改动,必须先停下来问用户,不能偷偷一起提交,也不能擅自丢掉。
|
|
||||||
3. 提交信息统一使用:
|
|
||||||
|
|
||||||
```text
|
|
||||||
chore(release): bump version to vX.Y.Z
|
|
||||||
```
|
|
||||||
|
|
||||||
示例:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git add -A
|
|
||||||
git commit -m "chore(release): bump version to $TargetTag"
|
|
||||||
```
|
|
||||||
|
|
||||||
提交后,必须记录真实提交哈希:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git rev-parse HEAD
|
|
||||||
```
|
|
||||||
|
|
||||||
## 阶段 3:分析“距离上一个版本更新了什么”
|
|
||||||
|
|
||||||
### 1. 获取上一个正式 Release
|
|
||||||
|
|
||||||
执行:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$PreviousReleaseTag = ([string](& $GH api "repos/$Repo/releases?per_page=100" --jq "[.[] | select(.draft == false and .prerelease == false)][0].tag_name")).Trim()
|
|
||||||
$PreviousReleaseTag
|
|
||||||
```
|
|
||||||
|
|
||||||
规则:
|
|
||||||
|
|
||||||
1. 默认取最新一个正式 Release 作为“上一个版本”。
|
|
||||||
2. 如果仓库还没有任何正式 Release,再退回到本地最新 tag:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
if (-not $PreviousReleaseTag) {
|
|
||||||
$PreviousReleaseTag = ([string](git tag --sort=-creatordate | Select-Object -First 1)).Trim()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
3. 如果仍然拿不到上一个版本,就按“首个版本发布”处理,并明确告诉用户这是首发版文案。
|
|
||||||
|
|
||||||
### 2. 拉真实变更范围
|
|
||||||
|
|
||||||
如果存在上一个版本,执行:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git log --oneline "$PreviousReleaseTag"..HEAD
|
|
||||||
git diff --stat "$PreviousReleaseTag"..HEAD
|
|
||||||
git diff --name-only "$PreviousReleaseTag"..HEAD
|
|
||||||
```
|
|
||||||
|
|
||||||
然后根据变更文件继续往下读真实 diff,不能只看 commit 标题。
|
|
||||||
|
|
||||||
必要时继续执行:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git diff "$PreviousReleaseTag"..HEAD -- <具体文件路径>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 文案分析要求
|
|
||||||
|
|
||||||
AI 必须基于真实 diff 和真实代码上下文,分析并提炼:
|
|
||||||
|
|
||||||
- 新增了什么功能
|
|
||||||
- 修复了什么问题
|
|
||||||
- 优化了什么逻辑
|
|
||||||
- 删除了什么无用或陈旧逻辑
|
|
||||||
- 哪些点最值得放进发布说明
|
|
||||||
|
|
||||||
禁止行为:
|
|
||||||
|
|
||||||
- 只看 commit message 就编文案
|
|
||||||
- 把没有真实证据的内容写进发布说明
|
|
||||||
- 把技术细节胡乱拔高成“重大更新”
|
|
||||||
|
|
||||||
## 阶段 4:先给用户看发布文案,不要直接发布
|
|
||||||
|
|
||||||
在真正发布前,AI 必须把文案返回给用户确认。
|
|
||||||
|
|
||||||
返回给用户时,必须同时说明:
|
|
||||||
|
|
||||||
1. `manifest.json` 已改到哪个版本
|
|
||||||
2. 是否已经提交
|
|
||||||
3. 本次提交哈希是什么
|
|
||||||
4. 上一个版本标签是什么
|
|
||||||
5. 发布文案草稿是什么
|
|
||||||
|
|
||||||
发布文案草稿必须放在代码块中,格式建议如下:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## vX.Y.Z
|
|
||||||
|
|
||||||
### 更新内容
|
|
||||||
- ...
|
|
||||||
- ...
|
|
||||||
|
|
||||||
### 修复与优化
|
|
||||||
- ...
|
|
||||||
- ...
|
|
||||||
```
|
|
||||||
|
|
||||||
注意:
|
|
||||||
|
|
||||||
1. 这里必须先停下来等用户确认。
|
|
||||||
2. 用户没确认前,不准创建 GitHub Release。
|
|
||||||
3. 如果用户要求改文案,只改文案,不要乱改已经完成的代码与提交,除非用户明确要求。
|
|
||||||
|
|
||||||
## 阶段 5:用户确认后再发布 GitHub Release
|
|
||||||
|
|
||||||
### 1. 先推送当前提交
|
|
||||||
|
|
||||||
发布前先把当前分支 HEAD 推到远端:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
git push origin HEAD
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 把用户确认过的文案写入 UTF-8 文件
|
|
||||||
|
|
||||||
不要直接把长正文硬塞进命令参数里,先写入临时文件。
|
|
||||||
|
|
||||||
示例:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$ReleaseNotesFile = Join-Path $env:TEMP "codex-release-$TargetVersion.md"
|
|
||||||
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
|
||||||
[System.IO.File]::WriteAllText($ReleaseNotesFile, $ReleaseNotes, $Utf8NoBom)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 创建 Release
|
|
||||||
|
|
||||||
执行:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
& $GH release create $TargetTag `
|
|
||||||
--repo $Repo `
|
|
||||||
--target HEAD `
|
|
||||||
--title $TargetTag `
|
|
||||||
--notes-file $ReleaseNotesFile
|
|
||||||
```
|
|
||||||
|
|
||||||
要求:
|
|
||||||
|
|
||||||
1. 标题默认使用 `vX.Y.Z`。
|
|
||||||
2. 正文必须使用用户确认过的最终文案。
|
|
||||||
3. 不要在用户没确认前抢先发版。
|
|
||||||
|
|
||||||
## 阶段 6:发布后线上正文复检
|
|
||||||
|
|
||||||
Release 创建完成后,必须立刻重新读取线上内容。
|
|
||||||
|
|
||||||
执行:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
& $GH api "repos/$Repo/releases/tags/$TargetTag"
|
|
||||||
& $GH api "repos/$Repo/releases/tags/$TargetTag" --jq '.html_url'
|
|
||||||
& $GH api "repos/$Repo/releases/tags/$TargetTag" --jq '.tag_name'
|
|
||||||
& $GH api "repos/$Repo/releases/tags/$TargetTag" --jq '.name'
|
|
||||||
& $GH api "repos/$Repo/releases/tags/$TargetTag" --jq '.body'
|
|
||||||
```
|
|
||||||
|
|
||||||
复检重点:
|
|
||||||
|
|
||||||
1. 线上 tag 是否真的是目标 tag
|
|
||||||
2. 线上标题是否真的是目标版本
|
|
||||||
3. 线上正文是否完整
|
|
||||||
4. 正文是否与用户确认稿一致
|
|
||||||
5. 是否存在乱码、异常字符、错误换行、空正文
|
|
||||||
|
|
||||||
尤其注意下面这些异常:
|
|
||||||
|
|
||||||
- 出现明显乱码字符
|
|
||||||
- 中文被破坏
|
|
||||||
- 正文为空
|
|
||||||
- 版本号不对
|
|
||||||
- 正文内容被截断
|
|
||||||
|
|
||||||
### 如果复检发现异常
|
|
||||||
|
|
||||||
必须立刻修复,不准带病汇报完成。
|
|
||||||
|
|
||||||
修复示例:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
& $GH release edit $TargetTag `
|
|
||||||
--repo $Repo `
|
|
||||||
--title $TargetTag `
|
|
||||||
--notes-file $ReleaseNotesFile
|
|
||||||
```
|
|
||||||
|
|
||||||
修复后,再重复执行一次“发布后线上正文复检”,直到线上内容正常为止。
|
|
||||||
|
|
||||||
## 用户侧最终反馈要求
|
|
||||||
|
|
||||||
AI 完成后,对用户的最终反馈至少要说明:
|
|
||||||
|
|
||||||
1. 已把 `manifest.json` 更新到哪个版本
|
|
||||||
2. 是否已经提交,提交哈希是什么
|
|
||||||
3. 上一个版本是什么
|
|
||||||
4. 发布文案是否经过用户确认
|
|
||||||
5. 是否已经创建 GitHub Release
|
|
||||||
6. 是否已经完成线上正文复检
|
|
||||||
7. 如果没跑测试,要明确提醒用户自行测试
|
|
||||||
|
|
||||||
## 一句话执行要求
|
|
||||||
|
|
||||||
当用户引用本文并说“更新版本到 x.x.x”时,AI 必须按下面顺序执行:
|
|
||||||
|
|
||||||
1. 真实检查工作区与当前版本
|
|
||||||
2. 真实校验目标版本是否合法且未发布
|
|
||||||
3. 修改 `manifest.json` 版本号
|
|
||||||
4. 提交本次待发布改动
|
|
||||||
5. 基于上一个正式 Release 到当前 HEAD 的真实差异分析更新内容
|
|
||||||
6. 把发布文案放进代码块返回给用户确认
|
|
||||||
7. 得到确认后再推送并创建 GitHub Release
|
|
||||||
8. 发布完成后再次读取线上正文并检查是否乱码
|
|
||||||
9. 如有异常,先修复再汇报
|
|
||||||
|
|
||||||
不能跳步骤,不能猜,不能偷懒。
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 164 KiB |
@@ -0,0 +1,338 @@
|
|||||||
|
# 仓库协作者用 AI 分析 PR 与合并标准流程
|
||||||
|
|
||||||
|
## 用途
|
||||||
|
|
||||||
|
本文件用于给仓库协作者自己电脑上的 AI 一个固定、完整、可重复执行的 PR 处理流程。
|
||||||
|
|
||||||
|
下次只要告诉 AI:
|
||||||
|
|
||||||
|
- 本仓库根目录
|
||||||
|
- 要处理的 PR 编号
|
||||||
|
- 是否只分析,还是分析后需要继续合并
|
||||||
|
|
||||||
|
再把本文件提供给 AI,AI 就必须按本文件执行,不能自行脑补流程。
|
||||||
|
|
||||||
|
## 仓库硬性规则
|
||||||
|
|
||||||
|
1. 任何结论都不能猜,必须基于真实命令输出、真实 diff、真实代码上下文。
|
||||||
|
2. 优先使用当前环境可直接执行的 `gh`、`git` 命令;如果命令不存在、认证失败、权限不足,必须立刻明确告知用户并停止,不能假装已经操作成功。
|
||||||
|
3. 本仓库自动处理的唯一目标分支是 `dev`。AI 不得把其他作者的 PR 直接作为自动流程合并到 `master`。
|
||||||
|
4. 任何分支判断都必须以实时 `gh pr view` 返回的 `baseRefName` 为准,不能只信用户口述、PR 标题或截图描述。
|
||||||
|
5. 如果 PR 当前目标分支是 `master`,AI 必须先自动把它改到 `dev`,然后重新拉取 PR 元数据与 diff,再继续分析。
|
||||||
|
6. 如果 PR 当前目标分支既不是 `dev` 也不是 `master`,AI 不得擅自改到别的分支,必须停止并告诉用户当前自动流程只支持 `dev`。
|
||||||
|
7. 如果把 `master` 转到 `dev` 时发现同一个来源仓库 + 来源分支已经存在另一个指向 `dev` 的打开 PR,AI 不得继续硬转,不得继续自动合并,必须先反馈重复 PR 问题。
|
||||||
|
8. 用户当前工作区可能是脏的,不能直接在当前工作区切分支、切换分支、硬合并。需要使用临时 `worktree`。
|
||||||
|
9. 如果合并后需要本地修复,优先删除无用旧逻辑,避免继续堆积陈旧代码。
|
||||||
|
10. 默认不编译测试。处理完成后提醒用户自行测试。
|
||||||
|
11. 任何 GitHub 评论、回复、感谢留言发出后,AI 都必须立即再读一遍线上实际内容,确认正文不是乱码、不是 `?`、不是编码异常;如果发现异常,必须立刻修正后再继续后续流程。
|
||||||
|
12. 如果流程执行过程中,PR 的实时目标分支被别人改掉,或者不再是 `dev`,AI 必须停止当前合并流程,重新拉取信息后再决定下一步。
|
||||||
|
13. 进入“阶段 2:分析与审查”时,AI 必须先给当前 PR 添加 `审查中` 标签,并在开始正式审查前确认该标签已经在线上生效。
|
||||||
|
|
||||||
|
## 输入要求
|
||||||
|
|
||||||
|
AI 至少要拿到下面这些输入:
|
||||||
|
|
||||||
|
- `PR 编号`
|
||||||
|
- `仓库路径`
|
||||||
|
- 本次目标:`只分析` / `分析后需要继续合并`
|
||||||
|
- 如果需要继续合并:是否允许 AI 直接本地修复冲突和问题
|
||||||
|
|
||||||
|
补充要求:
|
||||||
|
|
||||||
|
- 如果 AI 不能从当前仓库自动识别 `OWNER/REPO`,则用户还需要补充提供。
|
||||||
|
- 本流程默认允许 AI 在检测到 `master` 为目标分支时,直接远程改成 `dev`;如果用户明确禁止任何远程修改,则本流程不适用。
|
||||||
|
|
||||||
|
## 总流程
|
||||||
|
|
||||||
|
### 阶段 1:准备、目标分支校正与信息拉取
|
||||||
|
|
||||||
|
AI 必须先执行下面这些动作,不能跳步:
|
||||||
|
|
||||||
|
1. 检查当前仓库状态:
|
||||||
|
- `git status --short --branch`
|
||||||
|
- `git remote -v`
|
||||||
|
2. 拉取 PR 元数据:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
gh pr view <PR_NUMBER> --repo <OWNER/REPO> --json number,title,body,author,baseRefName,headRefName,headRepository,headRepositoryOwner,changedFiles,additions,deletions,commits,files,labels,isDraft,mergeStateStatus,mergeable,state,url
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 先根据实时 `baseRefName` 处理目标分支:
|
||||||
|
- 如果 `baseRefName = dev`:继续下一步。
|
||||||
|
- 如果 `baseRefName = master`:先执行“自动转到 `dev`”流程,再继续下一步。
|
||||||
|
- 如果 `baseRefName` 既不是 `dev` 也不是 `master`:立即停止,并向用户反馈“当前自动流程只支持 `dev`”。
|
||||||
|
|
||||||
|
4. 当 `baseRefName = master` 时,必须执行下面的自动转分支流程:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
gh api --method GET repos/<OWNER/REPO>/pulls -f state=open -f head='<HEAD_OWNER>:<HEAD_REF>' -f base='dev' -f per_page='100'
|
||||||
|
```
|
||||||
|
|
||||||
|
处理规则:
|
||||||
|
|
||||||
|
1. 先从上一步 PR 元数据中读取真实的 `HEAD_OWNER` 与 `HEAD_REF`,不能猜。
|
||||||
|
2. 如果查询结果中已经存在 `number != <PR_NUMBER>` 的打开 PR,则视为“重复的 dev PR”:
|
||||||
|
- 不再执行转分支
|
||||||
|
- 不再继续自动分析旧 diff
|
||||||
|
- 先反馈给当前用户,必要时再给 PR 留言说明
|
||||||
|
3. 如果不存在重复的 dev PR,则执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
gh pr edit <PR_NUMBER> --base dev --repo <OWNER/REPO>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 改完目标分支后,必须重新执行一次 PR 元数据拉取,确认新的 `baseRefName = dev`。
|
||||||
|
5. 在重新确认 `baseRefName = dev` 之前,不允许继续分析旧 diff,不允许继续合并。
|
||||||
|
|
||||||
|
5. 只有在目标分支已经确认是 `dev` 后,才能拉取 PR diff:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
gh pr diff <PR_NUMBER> --repo <OWNER/REPO>
|
||||||
|
```
|
||||||
|
|
||||||
|
6. 拉取目标分支与 PR 头:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git fetch origin dev
|
||||||
|
git fetch origin pull/<PR_NUMBER>/head:refs/remotes/origin/pr-<PR_NUMBER>
|
||||||
|
```
|
||||||
|
|
||||||
|
7. 如果需要稳定查看行号、文件内容、避免污染当前工作区,创建临时 `worktree`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$TempWorktree = '<临时目录>'
|
||||||
|
git worktree add --detach $TempWorktree origin/pr-<PR_NUMBER>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 阶段 2:分析与审查
|
||||||
|
|
||||||
|
AI 分析时,必须完整执行下面这些要求:
|
||||||
|
|
||||||
|
1. 审查一开始必须先执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
gh pr edit <PR_NUMBER> --add-label "审查中" --repo <OWNER/REPO>
|
||||||
|
```
|
||||||
|
|
||||||
|
补充要求:
|
||||||
|
|
||||||
|
- 打标后必须立刻重新读取一次实时 PR 元数据,确认 `labels` 中已经出现 `审查中`。
|
||||||
|
- 如果仓库里没有 `审查中` 标签、当前账号无权限打标,或者命令执行失败,必须立刻告知用户,不能假装已经开始审查。
|
||||||
|
2. 不能只看 PR 描述,必须结合真实 diff 和仓库现有代码上下文一起分析。
|
||||||
|
3. 如果阶段 1 发生过 `master -> dev` 自动转向,分析时只能基于转向后的最新 diff,不得继续引用转向前的 diff 结论。
|
||||||
|
4. 对于高风险文件,必须继续读取改动函数周边逻辑,确认消息流、状态流、配置项、回调流程、页面跳转流程是否一致。
|
||||||
|
5. 必须分析并输出:
|
||||||
|
- 这个 PR 改了什么
|
||||||
|
- 这些改动是否合理
|
||||||
|
- 是否存在 bug / 风险 / 逻辑冲突
|
||||||
|
- PR 当前是否可直接合并,例如 `mergeable`、`mergeStateStatus`
|
||||||
|
6. 如果发现问题,结论必须按严重级别排序,优先写真正会影响功能、合并或后续维护的问题。
|
||||||
|
7. 如果没有发现明确问题,也要说明剩余风险,例如:
|
||||||
|
- 未运行测试
|
||||||
|
- 需要人工验证真实业务接口
|
||||||
|
- 当前仅完成静态分析
|
||||||
|
8. 在准备进入后续合并步骤前,必须再拉取一次实时 PR 元数据,确认:
|
||||||
|
- PR 仍然是打开状态
|
||||||
|
- PR 不是 draft
|
||||||
|
- `baseRefName` 仍然是 `dev`
|
||||||
|
|
||||||
|
## 分析后评论规则
|
||||||
|
|
||||||
|
### 有问题 / 审核不通过时
|
||||||
|
|
||||||
|
如果发现需要作者或维护者注意的问题,AI 必须自动发 PR 评论,并且评论格式必须固定如下:
|
||||||
|
|
||||||
|
```text
|
||||||
|
(自动回复)AI分析结果(不一定完全正确,请仓库协作者确认无问题后再继续):
|
||||||
|
|
||||||
|
<这里写正式评论内容>
|
||||||
|
```
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
1. 第一行必须完全使用上面的标题,不能改字。
|
||||||
|
2. 标题下方必须空一行,再写正文。
|
||||||
|
3. 正文内容要直接写问题,不要再套娃解释“下面是分析结果”。
|
||||||
|
4. 评论内容必须基于实际检查到的问题,不能编。
|
||||||
|
5. 评论发出后,必须立刻回读该评论的线上正文,确认不是乱码;如果有乱码,必须先修正评论,再继续后续动作。
|
||||||
|
6. 如果问题能够明确定位到某个改动文件、代码块或行号,AI 应优先在对应文件的代码 diff / 代码附件上追加行级评论,直接指出问题和期望修改方式。
|
||||||
|
7. 如果结论是“当前不能通过审查”,AI 必须明确写出“需要修改后再继续”,不能只写模糊提醒。
|
||||||
|
8. 如果当前环境支持正式 PR review,且问题足以阻止合并,优先使用带“要求更改(Request changes)”语义的审查,而不是只留普通闲聊式评论。
|
||||||
|
9. 行级评论是对总评论的补充,不得用零散行级评论替代总评论结论。
|
||||||
|
|
||||||
|
### 没问题时
|
||||||
|
|
||||||
|
如果没有发现明确问题:
|
||||||
|
|
||||||
|
1. 先把分析结果反馈给当前用户。
|
||||||
|
2. 不强制给 PR 留问题评论。
|
||||||
|
3. 是否继续合并,取决于用户命令。
|
||||||
|
|
||||||
|
## 带问题继续合并的规则
|
||||||
|
|
||||||
|
如果 PR 有问题,但用户明确要求:
|
||||||
|
|
||||||
|
- 不等待 PR 作者修复
|
||||||
|
- 由当前 AI 直接修改 PR 分支代码,处理冲突和问题
|
||||||
|
- 处理完成后继续合并
|
||||||
|
|
||||||
|
则必须进入下面的流程。
|
||||||
|
|
||||||
|
### 阶段 3:临时工作区修复 PR 分支
|
||||||
|
|
||||||
|
禁止在用户当前工作区直接乱合并。
|
||||||
|
|
||||||
|
必须先创建临时 `worktree`,再在临时目录内处理 PR 分支:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git fetch origin dev
|
||||||
|
git fetch origin pull/<PR_NUMBER>/head:refs/remotes/origin/pr-<PR_NUMBER>
|
||||||
|
git worktree add <TEMP_WORKTREE> -b pr-<PR_NUMBER>-fix origin/pr-<PR_NUMBER>
|
||||||
|
```
|
||||||
|
|
||||||
|
进入临时目录后,先把最新 `dev` 合到 PR 分支里,显式暴露冲突:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git merge --no-ff --no-commit origin/dev
|
||||||
|
```
|
||||||
|
|
||||||
|
处理规则:
|
||||||
|
|
||||||
|
1. 如果阶段 1 发生过 `master -> dev` 自动转向,本阶段必须以 `dev` 为唯一合并基准,不允许再回到 `master` 做本地吸收。
|
||||||
|
2. 如果出现冲突,先在 PR 分支上解决冲突,再继续检查相关联逻辑。
|
||||||
|
3. 不能只消掉冲突标记就结束,必须继续看是否有设计冲突、状态字段不一致、调用链断裂、配置项名不一致、回调逻辑互相打架的问题。
|
||||||
|
4. 如果 PR 本身逻辑有 bug,而用户又明确要求继续合并,AI 需要直接修改 PR 分支代码并修正。
|
||||||
|
5. 修正完成后,必须把修改提交回 PR 分支并推送到远端;如果没有权限推送到 PR 来源分支,必须立刻停止并明确告知用户,不能假装 PR 已修好。
|
||||||
|
6. 推送完成后,必须重新拉取一次实时 PR 元数据与最新 diff,确认线上 PR 已包含本次修复,再决定是否继续合并。
|
||||||
|
7. 修正时要清理无用旧代码,避免留下史山。
|
||||||
|
8. 如果改了 SQL,按仓库规则同步本地 MySQL `xzs`。
|
||||||
|
|
||||||
|
### 阶段 4:PR 修复推送后的自检清单
|
||||||
|
|
||||||
|
完成 PR 分支修复并推送后,必须自检下面这些项目:
|
||||||
|
|
||||||
|
1. `git status` 中不能再有未处理冲突。
|
||||||
|
2. 相关文件中不能残留冲突标记。
|
||||||
|
3. 新旧逻辑之间不能出现字段名、消息名、步骤编号、状态名不一致。
|
||||||
|
4. 需要检查与本次功能直接相关的上下游代码,不能只改当前冲突文件。
|
||||||
|
5. 要重新查看最终线上 diff,确认修复后的 PR 没有引入明显回归。
|
||||||
|
6. 要重新拉取一次实时 PR 元数据,确认该 PR 的 `baseRefName` 仍然是 `dev`;如果不是,停止后续合并并先反馈用户。
|
||||||
|
7. 要确认 PR 当前已经回到可继续处理的状态,例如不再是 `draft`,且 `mergeable` / `mergeStateStatus` 没有出现新的阻塞。
|
||||||
|
8. 默认不编译测试,最后提醒用户自行测试。
|
||||||
|
|
||||||
|
## 合并提交信息规则
|
||||||
|
|
||||||
|
如果最后决定真正合并,提交信息不能直接写:
|
||||||
|
|
||||||
|
- `merge branch xxx`
|
||||||
|
- `merge pr #19`
|
||||||
|
- `合并某某分支`
|
||||||
|
|
||||||
|
必须重新分析“最终合并结果到底做了什么”,然后重写提交信息。
|
||||||
|
|
||||||
|
这些规则同样适用于最终执行 `gh pr merge` 时使用的标题与正文。
|
||||||
|
|
||||||
|
### 提交信息要求
|
||||||
|
|
||||||
|
1. 标题必须描述最终落地功能,不是描述 Git 动作。
|
||||||
|
2. 标题要体现核心改动结果,而不是“从哪里合并过来”。
|
||||||
|
3. 正文要写清楚:
|
||||||
|
- 这次吸收了 PR 的什么内容
|
||||||
|
- 本地额外修复了什么冲突或 bug
|
||||||
|
- 对哪些关键流程做了调整
|
||||||
|
|
||||||
|
### 推荐格式
|
||||||
|
|
||||||
|
```text
|
||||||
|
<type>: <最终功能或修复结果>
|
||||||
|
|
||||||
|
- 合并 PR #<PR_NUMBER> 的核心改动:<一句话概括>
|
||||||
|
- 本地补充修复:<一句话概括>
|
||||||
|
- 影响范围:<步骤/模块/页面/接口>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```text
|
||||||
|
feat: support SUB2API mode for OAuth generation and callback handling
|
||||||
|
|
||||||
|
- 合并 PR #19 的核心改动:新增 SUB2API 模式并接入 OAuth 生成与回调提交流程
|
||||||
|
- 本地补充修复:修正回调地址约束与超时重试带来的重复执行风险
|
||||||
|
- 影响范围:background orchestration、sidepanel config、sub2api content script
|
||||||
|
```
|
||||||
|
|
||||||
|
## 最终合并与收尾
|
||||||
|
|
||||||
|
如果 PR 分支上的冲突 / 问题已经修好,并且复检确认该 PR 可以继续合并到 `dev`,则继续执行下面动作。
|
||||||
|
|
||||||
|
### 1. 合并 PR
|
||||||
|
|
||||||
|
优先直接合并这个 PR,而不是只在本地偷偷吸收代码后手工关闭:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
gh pr merge <PR_NUMBER> --merge --subject "<TITLE>" --body "<BODY>" --repo <OWNER/REPO>
|
||||||
|
```
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
1. `--subject` 与 `--body` 必须遵守上面的“合并提交信息规则”。
|
||||||
|
2. 合并前必须再次确认 PR 仍然是打开状态、目标分支仍然是 `dev`、线上 diff 已包含你刚刚推送的修复。
|
||||||
|
3. 如果合并时发现新的冲突、状态检查阻塞或权限问题,必须停止并把真实阻塞原因反馈给用户。
|
||||||
|
|
||||||
|
### 2. 感谢作者
|
||||||
|
|
||||||
|
此时需要自动给 PR 留一条感谢评论。
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
1. 感谢评论不要带问题评论的固定标题。
|
||||||
|
2. 直接正常感谢即可。
|
||||||
|
3. 感谢内容要基于真实贡献,语气简洁。
|
||||||
|
4. 如果阶段 1 发生过 `master -> dev` 自动转向,感谢评论不能把最终落地分支写错,必须明确本次内容已吸收到 `dev`。
|
||||||
|
5. 感谢评论发出后,也必须立刻回读线上正文,确认不是乱码;如果有乱码,必须先修正评论,再进行关闭 PR 等后续动作。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
感谢贡献这次改动,核心思路和主体实现已经吸收进 dev 分支了。我这边补了一下合并过程里的冲突和相关修正,后续如果你还有类似改进也欢迎继续提交。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 自动关闭 PR
|
||||||
|
|
||||||
|
如果 PR 还没有因为合并而自动关闭,则感谢评论发完后,自动关闭该 PR:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
gh pr close <PR_NUMBER> --repo <OWNER/REPO>
|
||||||
|
```
|
||||||
|
|
||||||
|
如果需要,先评论再关闭,不要把感谢遗漏掉。
|
||||||
|
|
||||||
|
### 4. 评论编码复检
|
||||||
|
|
||||||
|
无论是问题评论、感谢评论,还是其他直接发到 GitHub 的回复,只要消息已经发出,AI 必须执行一次“发送后复检”:
|
||||||
|
|
||||||
|
1. 重新读取该评论/回复在线上的实际正文。
|
||||||
|
2. 检查是否存在乱码、异常问号、编码错乱、BOM 污染等问题。
|
||||||
|
3. 如果有问题,必须立即编辑修正,直到线上正文正常为止。
|
||||||
|
4. 编码复检完成前,不允许声称“评论已发送完成”。
|
||||||
|
|
||||||
|
## 用户侧最终反馈要求
|
||||||
|
|
||||||
|
AI 在对当前用户做最终反馈时,至少要说明:
|
||||||
|
|
||||||
|
1. 做了哪些真实动作
|
||||||
|
2. PR 原始目标分支是否是 `master`
|
||||||
|
3. 是否已经执行 `master -> dev` 自动转向
|
||||||
|
4. 是否发现重复的 `dev` PR
|
||||||
|
5. 是否发现问题
|
||||||
|
6. 是否已经给 PR 添加 `审查中` 标签
|
||||||
|
7. 是否已经发了 PR 评论 / 行级代码评论
|
||||||
|
8. 是否已经要求 PR 修改后再继续
|
||||||
|
9. 是否已经修改 PR 分支并推回远端
|
||||||
|
10. 是否已经完成 PR 合并
|
||||||
|
11. 是否已经关闭 PR
|
||||||
|
12. 如果改了代码但没跑测试,要明确提醒用户测试
|
||||||
|
|
||||||
|
## 给 AI 的一句话执行要求
|
||||||
|
|
||||||
|
拿到本文件后,AI 必须按“先真实读取 PR 元数据,先校正目标分支,再拉取最新 diff,在正式审查开始时先给 PR 打上 `审查中` 标签并确认已生效;如果审核不通过,就在对应代码附件上评论并明确要求更改;如果用户要求继续处理冲突和问题,就先修好并推回 PR 分支,再按规则合并 PR,最后感谢作者、关闭 PR”的顺序执行,不能跳步,不能猜,不能偷懒。
|
||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
(function icloudUtilsModule(root, factory) {
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = factory();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.IcloudUtils = factory();
|
||||||
|
})(typeof self !== 'undefined' ? self : globalThis, function createIcloudUtils() {
|
||||||
|
function normalizeIcloudHost(rawHost) {
|
||||||
|
const host = String(rawHost || '').trim().toLowerCase();
|
||||||
|
if (!host) return '';
|
||||||
|
if (host === 'icloud.com' || host === 'www.icloud.com' || host === 'setup.icloud.com') return 'icloud.com';
|
||||||
|
if (host === 'icloud.com.cn' || host === 'www.icloud.com.cn' || host === 'setup.icloud.com.cn') return 'icloud.com.cn';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfiguredIcloudHostPreference(stateOrValue = '') {
|
||||||
|
const preference = typeof stateOrValue === 'object'
|
||||||
|
? String(stateOrValue?.icloudHostPreference || '').trim().toLowerCase()
|
||||||
|
: String(stateOrValue || '').trim().toLowerCase();
|
||||||
|
if (!preference || preference === 'auto') return '';
|
||||||
|
return normalizeIcloudHost(preference);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIcloudLoginUrlForHost(host) {
|
||||||
|
const normalizedHost = normalizeIcloudHost(host);
|
||||||
|
if (normalizedHost === 'icloud.com') return 'https://www.icloud.com/';
|
||||||
|
if (normalizedHost === 'icloud.com.cn') return 'https://www.icloud.com.cn/';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIcloudSetupUrlForHost(host) {
|
||||||
|
const normalizedHost = normalizeIcloudHost(host);
|
||||||
|
if (normalizedHost === 'icloud.com') return 'https://setup.icloud.com/setup/ws/1';
|
||||||
|
if (normalizedHost === 'icloud.com.cn') return 'https://setup.icloud.com.cn/setup/ws/1';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIcloudHostHintFromMessage(message) {
|
||||||
|
const lower = String(message || '').toLowerCase();
|
||||||
|
if (lower.includes('setup.icloud.com.cn') || lower.includes('www.icloud.com.cn') || lower.includes('icloud.com.cn')) {
|
||||||
|
return 'icloud.com.cn';
|
||||||
|
}
|
||||||
|
if (lower.includes('setup.icloud.com') || lower.includes('www.icloud.com') || lower.includes('icloud.com')) {
|
||||||
|
return 'icloud.com';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBooleanMap(rawValue = {}) {
|
||||||
|
if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(rawValue).reduce((result, [key, value]) => {
|
||||||
|
const normalizedKey = String(key || '').trim().toLowerCase();
|
||||||
|
if (!normalizedKey) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result[normalizedKey] = Boolean(value);
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNormalizedEmailSet(values = []) {
|
||||||
|
if (values instanceof Set) {
|
||||||
|
return new Set(Array.from(values, (item) => String(item || '').trim().toLowerCase()).filter(Boolean));
|
||||||
|
}
|
||||||
|
if (Array.isArray(values)) {
|
||||||
|
return new Set(values.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean));
|
||||||
|
}
|
||||||
|
if (values && typeof values === 'object') {
|
||||||
|
const normalizedMap = normalizeBooleanMap(values);
|
||||||
|
return new Set(Object.entries(normalizedMap)
|
||||||
|
.filter(([, value]) => value)
|
||||||
|
.map(([email]) => email));
|
||||||
|
}
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
function findIcloudAliasArray(node, depth = 0) {
|
||||||
|
if (!node || depth > 4) return null;
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
return node.some((item) => item && typeof item === 'object') ? node : null;
|
||||||
|
}
|
||||||
|
if (typeof node !== 'object') return null;
|
||||||
|
|
||||||
|
const priorityKeys = ['hmeEmails', 'hmeEmailList', 'hmeList', 'hmes', 'aliases', 'items'];
|
||||||
|
for (const key of priorityKeys) {
|
||||||
|
if (Array.isArray(node[key])) return node[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const value of Object.values(node)) {
|
||||||
|
const nested = findIcloudAliasArray(value, depth + 1);
|
||||||
|
if (nested) return nested;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIcloudAliasRecord(raw, options = {}) {
|
||||||
|
const usedEmails = toNormalizedEmailSet(options.usedEmails);
|
||||||
|
const preservedEmails = toNormalizedEmailSet(options.preservedEmails);
|
||||||
|
const anonymousId = String(raw?.anonymousId || raw?.id || '').trim();
|
||||||
|
const email = String(
|
||||||
|
raw?.hme
|
||||||
|
|| raw?.email
|
||||||
|
|| raw?.alias
|
||||||
|
|| raw?.address
|
||||||
|
|| raw?.metaData?.hme
|
||||||
|
|| ''
|
||||||
|
).trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!email || !email.includes('@')) return null;
|
||||||
|
|
||||||
|
const label = String(raw?.label || raw?.metaData?.label || '').trim();
|
||||||
|
const note = String(raw?.note || raw?.metaData?.note || '').trim();
|
||||||
|
const state = String(raw?.state || raw?.status || '').trim().toLowerCase();
|
||||||
|
const createdAt = raw?.createTimestamp
|
||||||
|
|| raw?.createTime
|
||||||
|
|| raw?.createdAt
|
||||||
|
|| raw?.createdDate
|
||||||
|
|| null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
anonymousId,
|
||||||
|
email,
|
||||||
|
label,
|
||||||
|
note,
|
||||||
|
state,
|
||||||
|
active: raw?.active !== false && raw?.isActive !== false && state !== 'inactive' && state !== 'deleted',
|
||||||
|
used: usedEmails.has(email),
|
||||||
|
preserved: preservedEmails.has(email),
|
||||||
|
createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIcloudAliasList(response, options = {}) {
|
||||||
|
const aliases = findIcloudAliasArray(response);
|
||||||
|
if (!aliases) return [];
|
||||||
|
|
||||||
|
return aliases
|
||||||
|
.map((alias) => normalizeIcloudAliasRecord(alias, options))
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.active !== right.active) return left.active ? -1 : 1;
|
||||||
|
if (left.used !== right.used) return left.used ? 1 : -1;
|
||||||
|
return String(left.email).localeCompare(String(right.email));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickReusableIcloudAlias(aliases = []) {
|
||||||
|
return (Array.isArray(aliases) ? aliases : []).find((alias) => alias?.active && !alias?.used) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findIcloudAliasByEmail(aliases = [], email = '') {
|
||||||
|
const normalizedEmail = String(email || '').trim().toLowerCase();
|
||||||
|
if (!normalizedEmail) return null;
|
||||||
|
return (Array.isArray(aliases) ? aliases : [])
|
||||||
|
.find((alias) => String(alias?.email || '').trim().toLowerCase() === normalizedEmail) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
findIcloudAliasArray,
|
||||||
|
findIcloudAliasByEmail,
|
||||||
|
getConfiguredIcloudHostPreference,
|
||||||
|
getIcloudHostHintFromMessage,
|
||||||
|
getIcloudLoginUrlForHost,
|
||||||
|
getIcloudSetupUrlForHost,
|
||||||
|
normalizeBooleanMap,
|
||||||
|
normalizeIcloudAliasList,
|
||||||
|
normalizeIcloudAliasRecord,
|
||||||
|
normalizeIcloudHost,
|
||||||
|
pickReusableIcloudAlias,
|
||||||
|
toNormalizedEmailSet,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
(function luckmailUtilsModule(root, factory) {
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = factory();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.LuckMailUtils = factory();
|
||||||
|
})(typeof self !== 'undefined' ? self : globalThis, function createLuckmailUtils() {
|
||||||
|
const DEFAULT_LUCKMAIL_BASE_URL = 'https://mails.luckyous.com';
|
||||||
|
const DEFAULT_LUCKMAIL_EMAIL_TYPE = 'ms_graph';
|
||||||
|
const DEFAULT_LUCKMAIL_PROJECT_CODE = 'openai';
|
||||||
|
const DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME = '保留';
|
||||||
|
const LUCKMAIL_EMAIL_TYPES = ['self_built', 'ms_imap', 'ms_graph', 'google_variant'];
|
||||||
|
|
||||||
|
function firstNonEmptyString(values) {
|
||||||
|
for (const value of values) {
|
||||||
|
if (value === undefined || value === null) continue;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
if (normalized) return normalized;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTimestamp(value) {
|
||||||
|
if (!value) return 0;
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return value > 0 ? value : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawValue = String(value || '').trim();
|
||||||
|
const utcLikeMatch = rawValue.match(
|
||||||
|
/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/
|
||||||
|
);
|
||||||
|
if (utcLikeMatch && !/[zZ]|[+\-]\d{2}:?\d{2}$/.test(rawValue)) {
|
||||||
|
const [, year, month, day, hour, minute, second = '0'] = utcLikeMatch;
|
||||||
|
return Date.UTC(
|
||||||
|
Number(year),
|
||||||
|
Number(month) - 1,
|
||||||
|
Number(day),
|
||||||
|
Number(hour),
|
||||||
|
Number(minute),
|
||||||
|
Number(second),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = Date.parse(rawValue);
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLuckmailBaseUrl(rawValue = '') {
|
||||||
|
const value = String(rawValue || '').trim();
|
||||||
|
if (!value) return DEFAULT_LUCKMAIL_BASE_URL;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||||
|
return DEFAULT_LUCKMAIL_BASE_URL;
|
||||||
|
}
|
||||||
|
parsed.pathname = parsed.pathname.replace(/\/+$/, '');
|
||||||
|
parsed.search = '';
|
||||||
|
parsed.hash = '';
|
||||||
|
return parsed.toString().replace(/\/$/, '');
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_LUCKMAIL_BASE_URL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLuckmailEmailType(rawValue = '') {
|
||||||
|
const normalized = String(rawValue || '').trim().toLowerCase();
|
||||||
|
return LUCKMAIL_EMAIL_TYPES.includes(normalized)
|
||||||
|
? normalized
|
||||||
|
: DEFAULT_LUCKMAIL_EMAIL_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLuckmailProjectName(rawValue = '') {
|
||||||
|
return normalizeText(rawValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLuckmailVerificationCode(text) {
|
||||||
|
const source = String(text || '');
|
||||||
|
const matchCn = source.match(/(?:代码为|验证码[^0-9]*?)[\s::]*(\d{6})/i);
|
||||||
|
if (matchCn) return matchCn[1];
|
||||||
|
|
||||||
|
const matchChatGPT = source.match(/your\s+chatgpt\s+code\s+is\s+(\d{6})/i);
|
||||||
|
if (matchChatGPT) return matchChatGPT[1];
|
||||||
|
|
||||||
|
const matchEn = source.match(/code(?:\s+is|[\s:])+(\d{6})/i);
|
||||||
|
if (matchEn) return matchEn[1];
|
||||||
|
|
||||||
|
const matchStandalone = source.match(/\b(\d{6})\b/);
|
||||||
|
return matchStandalone ? matchStandalone[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLuckmailTag(item = {}) {
|
||||||
|
const safeItem = item && typeof item === 'object' ? item : {};
|
||||||
|
return {
|
||||||
|
id: Number(safeItem.id) || 0,
|
||||||
|
name: firstNonEmptyString([safeItem.name, safeItem.tag_name]),
|
||||||
|
remark: firstNonEmptyString([safeItem.remark]),
|
||||||
|
limit_type: Number(safeItem.limit_type) || 0,
|
||||||
|
purchase_count: Number(safeItem.purchase_count) || 0,
|
||||||
|
created_at: firstNonEmptyString([safeItem.created_at]) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLuckmailTags(input) {
|
||||||
|
const list = Array.isArray(input?.list)
|
||||||
|
? input.list
|
||||||
|
: (Array.isArray(input) ? input : []);
|
||||||
|
return list
|
||||||
|
.map((item) => normalizeLuckmailTag(item))
|
||||||
|
.filter((item) => item.id > 0 || item.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLuckmailPurchase(item = {}) {
|
||||||
|
const safeItem = item && typeof item === 'object' ? item : {};
|
||||||
|
const projectName = firstNonEmptyString([safeItem.project_name, safeItem.project]);
|
||||||
|
return {
|
||||||
|
id: Number(safeItem.id) || 0,
|
||||||
|
email_address: firstNonEmptyString([safeItem.email_address, safeItem.address]),
|
||||||
|
token: firstNonEmptyString([safeItem.token]),
|
||||||
|
project_name: projectName,
|
||||||
|
project_code: normalizeLuckmailProjectName(projectName),
|
||||||
|
price: firstNonEmptyString([safeItem.price]) || '0.0000',
|
||||||
|
status: Number(safeItem.status) || 0,
|
||||||
|
tag_id: Number(safeItem.tag_id) || 0,
|
||||||
|
tag_name: firstNonEmptyString([safeItem.tag_name]),
|
||||||
|
user_disabled: Number(safeItem.user_disabled) || 0,
|
||||||
|
warranty_hours: Number(safeItem.warranty_hours) || 0,
|
||||||
|
warranty_until: firstNonEmptyString([safeItem.warranty_until]) || null,
|
||||||
|
created_at: firstNonEmptyString([safeItem.created_at]) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLuckmailPurchases(result) {
|
||||||
|
const list = Array.isArray(result?.purchases)
|
||||||
|
? result.purchases
|
||||||
|
: (Array.isArray(result) ? result : []);
|
||||||
|
return list.map((item) => normalizeLuckmailPurchase(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLuckmailPurchaseListPage(result = {}) {
|
||||||
|
const safeResult = result && typeof result === 'object' ? result : {};
|
||||||
|
const list = Array.isArray(safeResult.list)
|
||||||
|
? safeResult.list
|
||||||
|
: (Array.isArray(safeResult.purchases) ? safeResult.purchases : []);
|
||||||
|
const total = Number(safeResult.total);
|
||||||
|
return {
|
||||||
|
list: list.map((item) => normalizeLuckmailPurchase(item)),
|
||||||
|
total: Number.isFinite(total) && total >= 0 ? total : 0,
|
||||||
|
page: Math.max(1, Number(safeResult.page) || 1),
|
||||||
|
page_size: Math.max(1, Number(safeResult.page_size || safeResult.pageSize) || list.length || 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLuckmailPurchaseId(value) {
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return String(Math.floor(numeric));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLuckmailUsedPurchases(rawValue = {}) {
|
||||||
|
if (!rawValue || typeof rawValue !== 'object' || Array.isArray(rawValue)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(rawValue).reduce((result, [key, value]) => {
|
||||||
|
const normalizedKey = normalizeLuckmailPurchaseId(key);
|
||||||
|
if (!normalizedKey) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result[normalizedKey] = Boolean(value);
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLuckmailPurchaseForProject(purchase, projectCode = DEFAULT_LUCKMAIL_PROJECT_CODE) {
|
||||||
|
const normalizedPurchase = normalizeLuckmailPurchase(purchase);
|
||||||
|
return normalizeLuckmailProjectName(normalizedPurchase.project_name) === normalizeLuckmailProjectName(projectCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLuckmailTokenMail(mail = {}) {
|
||||||
|
const safeMail = mail && typeof mail === 'object' ? mail : {};
|
||||||
|
const subject = firstNonEmptyString([safeMail.subject, safeMail.title]);
|
||||||
|
const body = firstNonEmptyString([safeMail.body, safeMail.body_text, safeMail.text]);
|
||||||
|
const htmlBody = firstNonEmptyString([safeMail.html_body, safeMail.body_html, safeMail.html]);
|
||||||
|
const from = firstNonEmptyString([safeMail.from, safeMail.sender]);
|
||||||
|
const verificationCode = firstNonEmptyString([safeMail.verification_code])
|
||||||
|
|| extractLuckmailVerificationCode([subject, body, htmlBody, from].filter(Boolean).join(' '));
|
||||||
|
|
||||||
|
return {
|
||||||
|
message_id: firstNonEmptyString([safeMail.message_id, safeMail.id]),
|
||||||
|
from,
|
||||||
|
subject,
|
||||||
|
body,
|
||||||
|
html_body: htmlBody,
|
||||||
|
received_at: firstNonEmptyString([safeMail.received_at, safeMail.receivedAt, safeMail.created_at]),
|
||||||
|
verification_code: verificationCode || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLuckmailTokenMails(input) {
|
||||||
|
const list = Array.isArray(input?.mails)
|
||||||
|
? input.mails
|
||||||
|
: (Array.isArray(input) ? input : []);
|
||||||
|
return list.map((mail) => normalizeLuckmailTokenMail(mail));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLuckmailTokenCode(result = {}) {
|
||||||
|
return {
|
||||||
|
email_address: firstNonEmptyString([result.email_address, result.address]),
|
||||||
|
project: firstNonEmptyString([result.project]),
|
||||||
|
has_new_mail: Boolean(result.has_new_mail),
|
||||||
|
verification_code: firstNonEmptyString([result.verification_code]) || null,
|
||||||
|
mail: result.mail ? normalizeLuckmailTokenMail(result.mail) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLuckmailMailCursor(cursor = {}) {
|
||||||
|
const safeCursor = cursor && typeof cursor === 'object' ? cursor : {};
|
||||||
|
return {
|
||||||
|
messageId: firstNonEmptyString([safeCursor.messageId, safeCursor.message_id]),
|
||||||
|
receivedAt: firstNonEmptyString([safeCursor.receivedAt, safeCursor.received_at]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLuckmailMailCursor(mail = {}) {
|
||||||
|
const normalizedMail = normalizeLuckmailTokenMail(mail);
|
||||||
|
return normalizeLuckmailMailCursor({
|
||||||
|
messageId: normalizedMail.message_id,
|
||||||
|
receivedAt: normalizedMail.received_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLuckmailBaselineCursor(mails) {
|
||||||
|
const latestMail = normalizeLuckmailTokenMails(mails)
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftTimestamp = normalizeTimestamp(left.received_at);
|
||||||
|
const rightTimestamp = normalizeTimestamp(right.received_at);
|
||||||
|
if (leftTimestamp !== rightTimestamp) {
|
||||||
|
return rightTimestamp - leftTimestamp;
|
||||||
|
}
|
||||||
|
return String(right.message_id || '').localeCompare(String(left.message_id || ''));
|
||||||
|
})[0] || null;
|
||||||
|
|
||||||
|
return latestMail ? buildLuckmailMailCursor(latestMail) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLuckmailMailNewerThanCursor(mail = {}, cursor = {}) {
|
||||||
|
const normalizedMail = normalizeLuckmailTokenMail(mail);
|
||||||
|
const normalizedCursor = normalizeLuckmailMailCursor(cursor);
|
||||||
|
|
||||||
|
if (!normalizedCursor.messageId && !normalizedCursor.receivedAt) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedMail.message_id && normalizedCursor.messageId && normalizedMail.message_id === normalizedCursor.messageId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mailTimestamp = normalizeTimestamp(normalizedMail.received_at);
|
||||||
|
const cursorTimestamp = normalizeTimestamp(normalizedCursor.receivedAt);
|
||||||
|
|
||||||
|
if (mailTimestamp && cursorTimestamp) {
|
||||||
|
if (mailTimestamp > cursorTimestamp) return true;
|
||||||
|
if (mailTimestamp < cursorTimestamp) return false;
|
||||||
|
return Boolean(
|
||||||
|
normalizedMail.message_id
|
||||||
|
&& normalizedCursor.messageId
|
||||||
|
&& normalizedMail.message_id !== normalizedCursor.messageId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedMail.message_id && normalizedCursor.messageId) {
|
||||||
|
return normalizedMail.message_id !== normalizedCursor.messageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !cursorTimestamp || Boolean(mailTimestamp && mailTimestamp > cursorTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLuckmailPurchaseExpired(purchase, now = Date.now()) {
|
||||||
|
const normalizedPurchase = normalizeLuckmailPurchase(purchase);
|
||||||
|
const expiresAt = normalizeTimestamp(normalizedPurchase.warranty_until);
|
||||||
|
return Boolean(expiresAt && expiresAt <= Number(now || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLuckmailPurchaseDisabled(purchase) {
|
||||||
|
return Number(normalizeLuckmailPurchase(purchase).user_disabled) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLuckmailPurchasePreserved(purchase, options = {}) {
|
||||||
|
const normalizedPurchase = normalizeLuckmailPurchase(purchase);
|
||||||
|
const expectedTagId = Number(options.preserveTagId) || 0;
|
||||||
|
const expectedTagName = normalizeText(options.preserveTagName || DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME);
|
||||||
|
|
||||||
|
if (expectedTagId > 0 && normalizedPurchase.tag_id === expectedTagId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(normalizedPurchase.tag_name && normalizeText(normalizedPurchase.tag_name) === expectedTagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLuckmailPurchaseReusable(purchase, options = {}) {
|
||||||
|
const normalizedPurchase = normalizeLuckmailPurchase(purchase);
|
||||||
|
const usedPurchases = normalizeLuckmailUsedPurchases(options.usedPurchases);
|
||||||
|
const purchaseId = normalizeLuckmailPurchaseId(normalizedPurchase.id);
|
||||||
|
|
||||||
|
if (!isLuckmailPurchaseForProject(normalizedPurchase, options.projectCode || DEFAULT_LUCKMAIL_PROJECT_CODE)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!normalizedPurchase.email_address || !normalizedPurchase.token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isLuckmailPurchaseDisabled(normalizedPurchase)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (purchaseId && usedPurchases[purchaseId]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isLuckmailPurchasePreserved(normalizedPurchase, options)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isLuckmailPurchaseExpired(normalizedPurchase, options.now || Date.now())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterReusableLuckmailPurchases(purchases, options = {}) {
|
||||||
|
const list = Array.isArray(purchases)
|
||||||
|
? purchases
|
||||||
|
: normalizeLuckmailPurchaseListPage(purchases).list;
|
||||||
|
return list
|
||||||
|
.map((purchase) => normalizeLuckmailPurchase(purchase))
|
||||||
|
.filter((purchase) => isLuckmailPurchaseReusable(purchase, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickReusableLuckmailPurchase(purchases, options = {}) {
|
||||||
|
return filterReusableLuckmailPurchases(purchases, options)[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mailMatchesLuckmailFilters(mail, filters = {}) {
|
||||||
|
const normalizedMail = normalizeLuckmailTokenMail(mail);
|
||||||
|
const afterTimestamp = normalizeTimestamp(filters.afterTimestamp);
|
||||||
|
const receivedAt = normalizeTimestamp(normalizedMail.received_at);
|
||||||
|
if (afterTimestamp && receivedAt && receivedAt < afterTimestamp) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderFilters = (filters.senderFilters || []).map(normalizeText).filter(Boolean);
|
||||||
|
const subjectFilters = (filters.subjectFilters || []).map(normalizeText).filter(Boolean);
|
||||||
|
const excludedCodes = new Set((filters.excludeCodes || []).filter(Boolean));
|
||||||
|
const combinedText = [
|
||||||
|
normalizedMail.subject,
|
||||||
|
normalizedMail.from,
|
||||||
|
normalizedMail.body,
|
||||||
|
normalizedMail.html_body,
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
const combinedTextNormalized = normalizeText(combinedText);
|
||||||
|
const senderNormalized = normalizeText(normalizedMail.from);
|
||||||
|
const subjectNormalized = normalizeText(normalizedMail.subject);
|
||||||
|
const code = normalizedMail.verification_code || extractLuckmailVerificationCode(combinedText);
|
||||||
|
|
||||||
|
if (!code || excludedCodes.has(code)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderMatch = senderFilters.length === 0
|
||||||
|
? true
|
||||||
|
: senderFilters.some((item) => senderNormalized.includes(item) || combinedTextNormalized.includes(item));
|
||||||
|
const subjectMatch = subjectFilters.length === 0
|
||||||
|
? true
|
||||||
|
: subjectFilters.some((item) => subjectNormalized.includes(item) || combinedTextNormalized.includes(item));
|
||||||
|
|
||||||
|
if (!senderMatch && !subjectMatch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
mail: normalizedMail,
|
||||||
|
receivedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickLuckmailVerificationMail(mails, filters = {}) {
|
||||||
|
const matches = normalizeLuckmailTokenMails(mails)
|
||||||
|
.map((mail) => mailMatchesLuckmailFilters(mail, filters))
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.receivedAt !== right.receivedAt) {
|
||||||
|
return right.receivedAt - left.receivedAt;
|
||||||
|
}
|
||||||
|
return String(right.mail.message_id || '').localeCompare(String(left.mail.message_id || ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
return matches[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
DEFAULT_LUCKMAIL_BASE_URL,
|
||||||
|
DEFAULT_LUCKMAIL_EMAIL_TYPE,
|
||||||
|
DEFAULT_LUCKMAIL_PROJECT_CODE,
|
||||||
|
DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME,
|
||||||
|
LUCKMAIL_EMAIL_TYPES,
|
||||||
|
buildLuckmailBaselineCursor,
|
||||||
|
buildLuckmailMailCursor,
|
||||||
|
extractLuckmailVerificationCode,
|
||||||
|
filterReusableLuckmailPurchases,
|
||||||
|
isLuckmailMailNewerThanCursor,
|
||||||
|
isLuckmailPurchaseDisabled,
|
||||||
|
isLuckmailPurchaseExpired,
|
||||||
|
isLuckmailPurchaseForProject,
|
||||||
|
isLuckmailPurchasePreserved,
|
||||||
|
isLuckmailPurchaseReusable,
|
||||||
|
normalizeLuckmailBaseUrl,
|
||||||
|
normalizeLuckmailEmailType,
|
||||||
|
normalizeLuckmailMailCursor,
|
||||||
|
normalizeLuckmailProjectName,
|
||||||
|
normalizeLuckmailPurchase,
|
||||||
|
normalizeLuckmailPurchaseId,
|
||||||
|
normalizeLuckmailPurchaseListPage,
|
||||||
|
normalizeLuckmailPurchases,
|
||||||
|
normalizeLuckmailTag,
|
||||||
|
normalizeLuckmailTags,
|
||||||
|
normalizeLuckmailTokenCode,
|
||||||
|
normalizeLuckmailTokenMail,
|
||||||
|
normalizeLuckmailTokenMails,
|
||||||
|
normalizeLuckmailUsedPurchases,
|
||||||
|
normalizeText,
|
||||||
|
normalizeTimestamp,
|
||||||
|
pickLuckmailVerificationMail,
|
||||||
|
pickReusableLuckmailPurchase,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -15,11 +15,22 @@
|
|||||||
"activeTab"
|
"activeTab"
|
||||||
],
|
],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
|
"https://*.icloud.com/*",
|
||||||
|
"https://*.icloud.com.cn/*",
|
||||||
"<all_urls>"
|
"<all_urls>"
|
||||||
],
|
],
|
||||||
"background": {
|
"background": {
|
||||||
"service_worker": "background.js"
|
"service_worker": "background.js"
|
||||||
},
|
},
|
||||||
|
"declarative_net_request": {
|
||||||
|
"rule_resources": [
|
||||||
|
{
|
||||||
|
"id": "icloud_headers",
|
||||||
|
"enabled": true,
|
||||||
|
"path": "rules.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"side_panel": {
|
"side_panel": {
|
||||||
"default_path": "sidepanel/sidepanel.html"
|
"default_path": "sidepanel/sidepanel.html"
|
||||||
},
|
},
|
||||||
|
|||||||
+275
-75
@@ -4,21 +4,91 @@
|
|||||||
/auth0\.openai\.com/i,
|
/auth0\.openai\.com/i,
|
||||||
];
|
];
|
||||||
const CODE_PATTERN = /\b(\d{6})\b/;
|
const CODE_PATTERN = /\b(\d{6})\b/;
|
||||||
const TOKEN_ENDPOINT = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token';
|
const GRAPH_SCOPES = 'offline_access https://graph.microsoft.com/Mail.Read https://graph.microsoft.com/User.Read';
|
||||||
const OUTLOOK_API_BASE = 'https://outlook.office.com/api/v2.0/me/messages';
|
const GRAPH_DEFAULT_SCOPE = 'https://graph.microsoft.com/.default';
|
||||||
|
const TOKEN_STRATEGIES = [
|
||||||
|
{
|
||||||
|
name: 'entra-common-delegated',
|
||||||
|
url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
||||||
|
extraData: { scope: GRAPH_SCOPES },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'entra-consumers-delegated',
|
||||||
|
url: 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token',
|
||||||
|
extraData: { scope: GRAPH_SCOPES },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'entra-common-default',
|
||||||
|
url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
||||||
|
extraData: { scope: GRAPH_DEFAULT_SCOPE },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'entra-common-outlook',
|
||||||
|
url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
|
||||||
|
extraData: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const TRANSPORT_PLANS = [
|
||||||
|
{
|
||||||
|
transport: 'graph',
|
||||||
|
strategyNames: ['entra-common-delegated', 'entra-consumers-delegated', 'entra-common-default'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
transport: 'outlook',
|
||||||
|
strategyNames: ['entra-common-outlook', 'entra-common-delegated', 'entra-consumers-delegated'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const GRAPH_API_BASE = 'https://graph.microsoft.com/v1.0/me/mailFolders';
|
||||||
|
const OUTLOOK_API_BASE = 'https://outlook.office.com/api/v2.0/me/mailfolders';
|
||||||
|
|
||||||
async function exchangeRefreshToken(clientId, refreshToken, options = {}) {
|
function getFetchImpl(fetchImpl) {
|
||||||
const fetchImpl = options.fetchImpl || globalScope.fetch;
|
const resolved = fetchImpl || globalScope.fetch;
|
||||||
if (typeof fetchImpl !== 'function') {
|
if (typeof resolved !== 'function') {
|
||||||
throw new Error('Microsoft 邮箱 helper 缺少 fetch 实现。');
|
throw new Error('Microsoft email helper requires a fetch implementation.');
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveTokenStrategy(name) {
|
||||||
|
return TOKEN_STRATEGIES.find((item) => item.name === name) || TOKEN_STRATEGIES[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMailboxLabel(mailbox = 'INBOX') {
|
||||||
|
return /^junk(?:\s*e-?mail|\s*email)?$/i.test(String(mailbox || '').trim()) ? 'Junk' : 'INBOX';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMailboxId(mailbox = 'INBOX') {
|
||||||
|
return normalizeMailboxLabel(mailbox) === 'Junk' ? 'junkemail' : 'inbox';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMailboxList(mailboxes) {
|
||||||
|
const list = Array.isArray(mailboxes) && mailboxes.length ? mailboxes : ['INBOX'];
|
||||||
|
return [...new Set(list.map((mailbox) => normalizeMailboxLabel(mailbox)))];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getResponseErrorText(response) {
|
||||||
|
const text = await response.text().catch(() => '');
|
||||||
|
if (!text) {
|
||||||
|
return response.statusText || `HTTP ${response.status}`;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
return parsed.error_description || parsed.error?.message || parsed.error || parsed.message || text;
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exchangeRefreshToken(clientId, refreshToken, options = {}) {
|
||||||
|
const fetchImpl = getFetchImpl(options.fetchImpl);
|
||||||
|
const strategy = resolveTokenStrategy(options.strategyName);
|
||||||
const body = new URLSearchParams({
|
const body = new URLSearchParams({
|
||||||
client_id: clientId,
|
client_id: clientId,
|
||||||
grant_type: 'refresh_token',
|
grant_type: 'refresh_token',
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
|
...(strategy.extraData || {}),
|
||||||
});
|
});
|
||||||
const response = await fetchImpl(TOKEN_ENDPOINT, {
|
const response = await fetchImpl(strategy.url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: body.toString(),
|
body: body.toString(),
|
||||||
@@ -26,75 +96,89 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text().catch(() => '');
|
throw new Error(`${strategy.name}: ${await getResponseErrorText(response)}`);
|
||||||
const errorInfo = (() => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
throw new Error(
|
|
||||||
errorInfo.error_description
|
|
||||||
|| `Token exchange failed (${response.status}): ${text.slice(0, 200)}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!data.access_token) {
|
if (!data.access_token) {
|
||||||
throw new Error('Token exchange response missing access_token.');
|
throw new Error(`${strategy.name}: token response missing access_token`);
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchOutlookMessages(accessToken, options = {}) {
|
return {
|
||||||
const { top = 5, signal } = options;
|
...data,
|
||||||
const fetchImpl = options.fetchImpl || globalScope.fetch;
|
tokenStrategy: strategy.name,
|
||||||
if (typeof fetchImpl !== 'function') {
|
};
|
||||||
throw new Error('Microsoft 邮箱 helper 缺少 fetch 实现。');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetchImpl(
|
async function fetchGraphMessages(accessToken, options = {}) {
|
||||||
`${OUTLOOK_API_BASE}?$top=${encodeURIComponent(top)}&$orderby=ReceivedDateTime desc&$select=From,Subject,ReceivedDateTime,BodyPreview,Body`,
|
const fetchImpl = getFetchImpl(options.fetchImpl);
|
||||||
{
|
const mailbox = normalizeMailboxLabel(options.mailbox);
|
||||||
|
const top = Math.max(1, Math.min(Number(options.top) || 5, 30));
|
||||||
|
const url = `${GRAPH_API_BASE}/${normalizeMailboxId(mailbox)}/messages?$top=${encodeURIComponent(top)}&$select=id,internetMessageId,subject,from,bodyPreview,receivedDateTime&$orderby=receivedDateTime desc`;
|
||||||
|
const response = await fetchImpl(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: {
|
||||||
signal,
|
Accept: 'application/json',
|
||||||
}
|
Authorization: `Bearer ${accessToken}`,
|
||||||
);
|
},
|
||||||
|
signal: options.signal,
|
||||||
if (response.status === 401 || response.status === 403) {
|
});
|
||||||
throw new Error('Microsoft Graph token invalid or expired.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.text().catch(() => '');
|
throw new Error(`graph: ${await getResponseErrorText(response)}`);
|
||||||
throw new Error(`Outlook API request failed (${response.status}): ${body || response.statusText}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
return Array.isArray(payload?.value) ? payload.value : [];
|
return Array.isArray(payload?.value) ? payload.value : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeMessage(message) {
|
async function fetchOutlookMessages(accessToken, options = {}) {
|
||||||
|
const fetchImpl = getFetchImpl(options.fetchImpl);
|
||||||
|
const mailbox = normalizeMailboxLabel(options.mailbox);
|
||||||
|
const top = Math.max(1, Math.min(Number(options.top) || 5, 30));
|
||||||
|
const url = `${OUTLOOK_API_BASE}/${normalizeMailboxId(mailbox)}/messages?$top=${encodeURIComponent(top)}&$select=Id,Subject,From,BodyPreview,Body,ReceivedDateTime&$orderby=ReceivedDateTime desc`;
|
||||||
|
const response = await fetchImpl(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
signal: options.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`outlook: ${await getResponseErrorText(response)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
return Array.isArray(payload?.value) ? payload.value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMessage(message, mailbox = 'INBOX') {
|
||||||
|
const sender = message?.From || message?.from || {};
|
||||||
|
const emailAddress = sender?.EmailAddress || sender?.emailAddress || {};
|
||||||
return {
|
return {
|
||||||
|
mailbox: normalizeMailboxLabel(mailbox || message?.mailbox),
|
||||||
from: {
|
from: {
|
||||||
emailAddress: {
|
emailAddress: {
|
||||||
address: message?.From?.EmailAddress?.Address
|
address: String(emailAddress?.Address || emailAddress?.address || '').trim(),
|
||||||
|| message?.from?.emailAddress?.address
|
name: String(emailAddress?.Name || emailAddress?.name || '').trim(),
|
||||||
|| '',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
subject: message?.Subject || message?.subject || '',
|
subject: String(message?.Subject || message?.subject || '').trim(),
|
||||||
receivedDateTime: message?.ReceivedDateTime || message?.receivedDateTime || '',
|
receivedDateTime: String(message?.ReceivedDateTime || message?.receivedDateTime || '').trim(),
|
||||||
bodyPreview: message?.BodyPreview || message?.bodyPreview || '',
|
bodyPreview: String(message?.BodyPreview || message?.bodyPreview || '').trim(),
|
||||||
body: {
|
body: {
|
||||||
content: message?.Body?.Content || message?.body?.content || '',
|
content: String(message?.Body?.Content || message?.body?.content || '').trim(),
|
||||||
},
|
},
|
||||||
id: message?.Id || message?.id || '',
|
id: String(message?.Id || message?.id || message?.internetMessageId || '').trim(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeFilterValue(value) {
|
||||||
|
return String(value || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
function getMessageSender(message) {
|
function getMessageSender(message) {
|
||||||
return String(
|
return String(
|
||||||
message?.from?.emailAddress?.address
|
message?.from?.emailAddress?.address
|
||||||
@@ -130,29 +214,54 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractVerificationCodeFromMessages(messages, options = {}) {
|
function extractVerificationCodeFromMessages(messages, options = {}) {
|
||||||
const { filterAfterTimestamp = 0 } = options;
|
const filterAfterTimestamp = Number(options.filterAfterTimestamp || 0) || 0;
|
||||||
|
const senderFilters = (options.senderFilters || []).map(normalizeFilterValue).filter(Boolean);
|
||||||
|
const subjectFilters = (options.subjectFilters || []).map(normalizeFilterValue).filter(Boolean);
|
||||||
|
const excludedCodes = new Set((options.excludeCodes || []).map((value) => String(value || '').trim()).filter(Boolean));
|
||||||
|
const hasExplicitFilters = senderFilters.length > 0 || subjectFilters.length > 0;
|
||||||
|
|
||||||
for (const raw of messages) {
|
const sortedMessages = (Array.isArray(messages) ? messages : [])
|
||||||
const message = normalizeMessage(raw);
|
.map((raw) => normalizeMessage(raw, raw?.mailbox))
|
||||||
|
.sort((left, right) => getMessageTimestamp(right) - getMessageTimestamp(left));
|
||||||
|
|
||||||
|
for (const message of sortedMessages) {
|
||||||
const receivedAt = getMessageTimestamp(message);
|
const receivedAt = getMessageTimestamp(message);
|
||||||
if (receivedAt && receivedAt < Number(filterAfterTimestamp || 0)) {
|
if (receivedAt && receivedAt < filterAfterTimestamp) {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!isOpenAiMessage(message)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = getMessageSearchText(message).match(CODE_PATTERN);
|
const sender = normalizeFilterValue(getMessageSender(message));
|
||||||
if (!match) {
|
const subject = normalizeFilterValue(message?.subject);
|
||||||
|
const preview = normalizeFilterValue(message?.bodyPreview);
|
||||||
|
const searchText = normalizeFilterValue(getMessageSearchText(message));
|
||||||
|
const codeMatch = getMessageSearchText(message).match(CODE_PATTERN);
|
||||||
|
const code = codeMatch?.[1] || '';
|
||||||
|
if (!code || excludedCodes.has(code)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasExplicitFilters && !isOpenAiMessage(message)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderMatched = senderFilters.length === 0
|
||||||
|
? true
|
||||||
|
: senderFilters.some((filter) => sender.includes(filter) || preview.includes(filter) || searchText.includes(filter));
|
||||||
|
const subjectMatched = subjectFilters.length === 0
|
||||||
|
? true
|
||||||
|
: subjectFilters.some((filter) => subject.includes(filter) || preview.includes(filter) || searchText.includes(filter));
|
||||||
|
if (!senderMatched && !subjectMatched) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
code: match[1],
|
code,
|
||||||
emailTimestamp: receivedAt || Date.now(),
|
emailTimestamp: receivedAt || Date.now(),
|
||||||
messageId: message?.id || null,
|
messageId: message?.id || null,
|
||||||
sender: getMessageSender(message),
|
sender: getMessageSender(message),
|
||||||
subject: String(message?.subject || ''),
|
subject: String(message?.subject || ''),
|
||||||
|
mailbox: message?.mailbox || 'INBOX',
|
||||||
|
message,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,9 +272,11 @@
|
|||||||
const {
|
const {
|
||||||
clientId,
|
clientId,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
|
mailbox = 'INBOX',
|
||||||
top = 5,
|
top = 5,
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
signal,
|
signal,
|
||||||
|
log = null,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
@@ -175,71 +286,160 @@
|
|||||||
throw new Error('Microsoft client_id is empty.');
|
throw new Error('Microsoft client_id is empty.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenData = await exchangeRefreshToken(clientId, refreshToken, { fetchImpl, signal });
|
const errors = [];
|
||||||
const rawMessages = await fetchOutlookMessages(tokenData.access_token, { top, signal, fetchImpl });
|
for (const plan of TRANSPORT_PLANS) {
|
||||||
|
for (const strategyName of plan.strategyNames) {
|
||||||
|
try {
|
||||||
|
const tokenData = await exchangeRefreshToken(clientId, refreshToken, {
|
||||||
|
fetchImpl,
|
||||||
|
signal,
|
||||||
|
strategyName,
|
||||||
|
});
|
||||||
|
const rawMessages = plan.transport === 'graph'
|
||||||
|
? await fetchGraphMessages(tokenData.access_token, { mailbox, top, fetchImpl, signal })
|
||||||
|
: await fetchOutlookMessages(tokenData.access_token, { mailbox, top, fetchImpl, signal });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tokenData,
|
tokenData,
|
||||||
nextRefreshToken: String(tokenData?.refresh_token || '').trim(),
|
nextRefreshToken: String(tokenData?.refresh_token || '').trim(),
|
||||||
messages: rawMessages.map((message) => normalizeMessage(message)),
|
tokenStrategy: strategyName,
|
||||||
|
transport: plan.transport,
|
||||||
|
mailbox: normalizeMailboxLabel(mailbox),
|
||||||
|
messages: rawMessages.map((message) => normalizeMessage(message, mailbox)),
|
||||||
};
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error?.message || String(error);
|
||||||
|
errors.push(`${plan.transport}/${strategyName}: ${message}`);
|
||||||
|
if (typeof log === 'function') {
|
||||||
|
log(`mailbox=${normalizeMailboxLabel(mailbox)} ${plan.transport}/${strategyName} failed: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Microsoft mailbox request failed: ${errors.join(' | ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(timeoutMs, signal) {
|
||||||
|
if (timeoutMs <= 0) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
}, timeoutMs);
|
||||||
|
const onAbort = () => {
|
||||||
|
cleanup();
|
||||||
|
reject(signal.reason || new Error('Aborted'));
|
||||||
|
};
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
signal?.removeEventListener('abort', onAbort);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
cleanup();
|
||||||
|
reject(signal.reason || new Error('Aborted'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
signal?.addEventListener('abort', onAbort, { once: true });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchMicrosoftVerificationCode(options = {}) {
|
async function fetchMicrosoftVerificationCode(options = {}) {
|
||||||
const {
|
const {
|
||||||
token,
|
token,
|
||||||
|
refreshToken,
|
||||||
clientId,
|
clientId,
|
||||||
maxRetries = 3,
|
maxRetries = 3,
|
||||||
retryDelayMs = 10000,
|
retryDelayMs = 10000,
|
||||||
|
top = 5,
|
||||||
log = null,
|
log = null,
|
||||||
filterAfterTimestamp = 0,
|
filterAfterTimestamp = 0,
|
||||||
|
senderFilters = [],
|
||||||
|
subjectFilters = [],
|
||||||
|
excludeCodes = [],
|
||||||
|
mailboxes = ['INBOX'],
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
signal,
|
signal,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
if (!token) {
|
let workingRefreshToken = String(refreshToken || token || '').trim();
|
||||||
|
if (!workingRefreshToken) {
|
||||||
throw new Error('Microsoft refresh token is empty.');
|
throw new Error('Microsoft refresh token is empty.');
|
||||||
}
|
}
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
throw new Error('Microsoft client_id is empty.');
|
throw new Error('Microsoft client_id is empty.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenData = await exchangeRefreshToken(clientId, token, { fetchImpl, signal });
|
const normalizedMailboxes = normalizeMailboxList(mailboxes);
|
||||||
const accessToken = tokenData.access_token;
|
let lastError = null;
|
||||||
const nextRefreshToken = String(tokenData?.refresh_token || '').trim();
|
|
||||||
|
|
||||||
for (let attempt = 1; attempt <= maxRetries; attempt += 1) {
|
for (let attempt = 1; attempt <= maxRetries; attempt += 1) {
|
||||||
const messages = await fetchOutlookMessages(accessToken, { top: 5, signal, fetchImpl });
|
try {
|
||||||
const result = extractVerificationCodeFromMessages(messages, { filterAfterTimestamp });
|
const collectedMessages = [];
|
||||||
if (result) {
|
for (const mailbox of normalizedMailboxes) {
|
||||||
|
const result = await fetchMicrosoftMailboxMessages({
|
||||||
|
clientId,
|
||||||
|
refreshToken: workingRefreshToken,
|
||||||
|
mailbox,
|
||||||
|
top,
|
||||||
|
fetchImpl,
|
||||||
|
signal,
|
||||||
|
log,
|
||||||
|
});
|
||||||
|
if (result.nextRefreshToken) {
|
||||||
|
workingRefreshToken = result.nextRefreshToken;
|
||||||
|
}
|
||||||
|
collectedMessages.push(...result.messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = extractVerificationCodeFromMessages(collectedMessages, {
|
||||||
|
filterAfterTimestamp,
|
||||||
|
senderFilters,
|
||||||
|
subjectFilters,
|
||||||
|
excludeCodes,
|
||||||
|
});
|
||||||
|
if (match) {
|
||||||
return {
|
return {
|
||||||
...result,
|
...match,
|
||||||
nextRefreshToken,
|
nextRefreshToken: workingRefreshToken,
|
||||||
messages: messages.map((message) => normalizeMessage(message)),
|
messages: collectedMessages,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastError = new Error('No matching Microsoft verification email found.');
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
|
||||||
if (attempt < maxRetries) {
|
if (attempt < maxRetries) {
|
||||||
if (typeof log === 'function') {
|
if (typeof log === 'function') {
|
||||||
log(`Outlook API: attempt ${attempt}/${maxRetries} found no OpenAI verification mail, retrying...`);
|
log(`attempt ${attempt}/${maxRetries} found no matching Microsoft mail, retrying...`);
|
||||||
}
|
}
|
||||||
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
await delay(retryDelayMs, signal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('No matching OpenAI verification email found.');
|
throw lastError || new Error('No matching Microsoft verification email found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
CODE_PATTERN,
|
CODE_PATTERN,
|
||||||
exchangeRefreshToken,
|
exchangeRefreshToken,
|
||||||
extractVerificationCodeFromMessages,
|
extractVerificationCodeFromMessages,
|
||||||
|
fetchGraphMessages,
|
||||||
fetchMicrosoftMailboxMessages,
|
fetchMicrosoftMailboxMessages,
|
||||||
fetchMicrosoftVerificationCode,
|
fetchMicrosoftVerificationCode,
|
||||||
fetchOutlookMessages,
|
fetchOutlookMessages,
|
||||||
getMessageSender,
|
getMessageSender,
|
||||||
getMessageTimestamp,
|
getMessageTimestamp,
|
||||||
isOpenAiMessage,
|
isOpenAiMessage,
|
||||||
|
normalizeMailboxId,
|
||||||
|
normalizeMailboxLabel,
|
||||||
normalizeMessage,
|
normalizeMessage,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"priority": 1,
|
||||||
|
"action": {
|
||||||
|
"type": "modifyHeaders",
|
||||||
|
"requestHeaders": [
|
||||||
|
{
|
||||||
|
"header": "Origin",
|
||||||
|
"operation": "set",
|
||||||
|
"value": "https://www.icloud.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"header": "Referer",
|
||||||
|
"operation": "set",
|
||||||
|
"value": "https://www.icloud.com/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"condition": {
|
||||||
|
"urlFilter": "|https://*.icloud.com/*",
|
||||||
|
"resourceTypes": ["xmlhttprequest"],
|
||||||
|
"excludedInitiatorDomains": ["apple.com", "icloud.com"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"priority": 1,
|
||||||
|
"action": {
|
||||||
|
"type": "modifyHeaders",
|
||||||
|
"requestHeaders": [
|
||||||
|
{
|
||||||
|
"header": "Origin",
|
||||||
|
"operation": "set",
|
||||||
|
"value": "https://www.icloud.com.cn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"header": "Referer",
|
||||||
|
"operation": "set",
|
||||||
|
"value": "https://www.icloud.com.cn/"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"condition": {
|
||||||
|
"urlFilter": "|https://*.icloud.com.cn/*",
|
||||||
|
"resourceTypes": ["xmlhttprequest"],
|
||||||
|
"excludedInitiatorDomains": ["apple.com.cn", "icloud.com.cn"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -698,6 +698,164 @@ header {
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.luckmail-card {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luckmail-manager-card {
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: color-mix(in srgb, var(--bg-base) 84%, transparent);
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luckmail-summary {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luckmail-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luckmail-search {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luckmail-filter {
|
||||||
|
flex: 0 0 130px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luckmail-bulkbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luckmail-select-all {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luckmail-bulk-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luckmail-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luckmail-item {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-base);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luckmail-item.is-current {
|
||||||
|
border-color: var(--blue);
|
||||||
|
box-shadow: 0 0 0 1px var(--blue-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.luckmail-item-check {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luckmail-item-main {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luckmail-item-email-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luckmail-item-email {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luckmail-item-meta,
|
||||||
|
.luckmail-item-details {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luckmail-item-details {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luckmail-item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.luckmail-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.luckmail-tag.used {
|
||||||
|
background: var(--orange-soft);
|
||||||
|
color: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.luckmail-tag.active {
|
||||||
|
background: var(--green-soft);
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.luckmail-tag.current {
|
||||||
|
background: var(--blue-soft);
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.luckmail-tag.disabled {
|
||||||
|
background: var(--red-soft);
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.luckmail-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.hotmail-actions-row .data-label {
|
.hotmail-actions-row .data-label {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
@@ -877,6 +1035,163 @@ header {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icloud-card {
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: color-mix(in srgb, var(--bg-base) 84%, transparent);
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icloud-summary {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icloud-login-help {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--orange) 26%, var(--border));
|
||||||
|
background: color-mix(in srgb, var(--orange-soft) 62%, var(--bg-base));
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icloud-login-help-main {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icloud-login-help-title {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icloud-login-help-text {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icloud-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icloud-search {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icloud-filter {
|
||||||
|
flex: 0 0 130px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icloud-bulkbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icloud-select-all {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icloud-bulk-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icloud-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icloud-item {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-base);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icloud-item-check {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icloud-item-main {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icloud-item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icloud-item-email {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icloud-item-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icloud-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icloud-tag.used {
|
||||||
|
background: var(--orange-soft);
|
||||||
|
color: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icloud-tag.active {
|
||||||
|
background: var(--green-soft);
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icloud-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.data-select {
|
.data-select {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
|
|||||||
@@ -154,22 +154,54 @@
|
|||||||
<select id="select-mail-provider" class="data-select">
|
<select id="select-mail-provider" class="data-select">
|
||||||
<option value="custom">自定义邮箱</option>
|
<option value="custom">自定义邮箱</option>
|
||||||
<option value="hotmail-api">Hotmail(账号池)</option>
|
<option value="hotmail-api">Hotmail(账号池)</option>
|
||||||
|
<option value="luckmail-api">LuckMail(API 购邮)</option>
|
||||||
<option value="163">163 邮箱 (mail.163.com)</option>
|
<option value="163">163 邮箱 (mail.163.com)</option>
|
||||||
<option value="163-vip">163 VIP 邮箱 (vip.163.com)</option>
|
<option value="163-vip">163 VIP 邮箱 (vip.163.com)</option>
|
||||||
<option value="qq">QQ 邮箱 (wx.mail.qq.com)</option>
|
<option value="qq">QQ 邮箱 (wx.mail.qq.com)</option>
|
||||||
<option value="inbucket">Inbucket(自定义主机)</option>
|
<option value="inbucket">Inbucket(自定义主机)</option>
|
||||||
<option value="2925">2925 邮箱 (2925.com)</option>
|
<option value="2925">2925 邮箱 (2925.com)</option>
|
||||||
|
<option value="cloudflare-temp-email">Cloudflare Temp Email</option>
|
||||||
</select>
|
</select>
|
||||||
<button id="btn-mail-login" class="btn btn-outline btn-sm data-inline-btn" type="button" disabled>登录</button>
|
<button id="btn-mail-login" class="btn btn-outline btn-sm data-inline-btn" type="button" disabled>登录</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="data-row" id="row-mail-2925-mode" style="display:none;">
|
||||||
|
<span class="data-label">2925 模式</span>
|
||||||
|
<div id="mail-2925-mode-group" class="choice-group" role="group" aria-label="2925 邮箱模式">
|
||||||
|
<button type="button" class="choice-btn" data-mail2925-mode="provide">提供邮箱</button>
|
||||||
|
<button type="button" class="choice-btn" data-mail2925-mode="receive">接收邮箱</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="data-row" id="row-email-generator">
|
<div class="data-row" id="row-email-generator">
|
||||||
<span class="data-label">邮箱生成</span>
|
<span class="data-label">邮箱生成</span>
|
||||||
<select id="select-email-generator" class="data-select">
|
<select id="select-email-generator" class="data-select">
|
||||||
<option value="duck">DuckDuckGo</option>
|
<option value="duck">DuckDuckGo</option>
|
||||||
<option value="cloudflare">Cloudflare</option>
|
<option value="cloudflare">Cloudflare</option>
|
||||||
|
<option value="icloud">iCloud 隐私邮箱</option>
|
||||||
|
<option value="cloudflare-temp-email">Cloudflare Temp Email</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="data-row" id="row-temp-email-base-url" style="display:none;">
|
||||||
|
<span class="data-label">Temp API</span>
|
||||||
|
<input type="text" id="input-temp-email-base-url" class="data-input" placeholder="https://your-worker-domain" />
|
||||||
|
</div>
|
||||||
|
<div class="data-row" id="row-temp-email-admin-auth" style="display:none;">
|
||||||
|
<span class="data-label">Admin Auth</span>
|
||||||
|
<input type="password" id="input-temp-email-admin-auth" class="data-input data-input-with-icon" placeholder="Cloudflare Temp Email admin password" />
|
||||||
|
</div>
|
||||||
|
<div class="data-row" id="row-temp-email-custom-auth" style="display:none;">
|
||||||
|
<span class="data-label">Custom Auth</span>
|
||||||
|
<input type="password" id="input-temp-email-custom-auth" class="data-input data-input-with-icon" placeholder="仅当站点启用了访问密码时再填写;这是额外鉴权,不替代 Admin Auth。" />
|
||||||
|
</div>
|
||||||
|
<div class="data-row" id="row-temp-email-domain" style="display:none;">
|
||||||
|
<span class="data-label">Temp 域名</span>
|
||||||
|
<div class="data-inline">
|
||||||
|
<select id="select-temp-email-domain" class="data-select"></select>
|
||||||
|
<input type="text" id="input-temp-email-domain" class="data-input" placeholder="例如 mail.example.com"
|
||||||
|
style="display:none;" />
|
||||||
|
<button id="btn-temp-email-domain-mode" class="btn btn-outline btn-sm" type="button">添加</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="data-row" id="row-cf-domain" style="display:none;">
|
<div class="data-row" id="row-cf-domain" style="display:none;">
|
||||||
<span class="data-label">CF 域名</span>
|
<span class="data-label">CF 域名</span>
|
||||||
<div class="data-inline">
|
<div class="data-inline">
|
||||||
@@ -316,6 +348,137 @@
|
|||||||
<div id="hotmail-accounts-list" class="hotmail-accounts-list"></div>
|
<div id="hotmail-accounts-list" class="hotmail-accounts-list"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="luckmail-section" class="data-card luckmail-card" style="display:none;">
|
||||||
|
<div class="section-mini-header">
|
||||||
|
<div class="section-mini-copy">
|
||||||
|
<span class="section-label">LuckMail 配置</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-row">
|
||||||
|
<span class="data-label">API Key</span>
|
||||||
|
<input type="password" id="input-luckmail-api-key" class="data-input mono" placeholder="请输入 LuckMail API Key" />
|
||||||
|
</div>
|
||||||
|
<div class="data-row">
|
||||||
|
<span class="data-label">Base URL</span>
|
||||||
|
<input type="text" id="input-luckmail-base-url" class="data-input mono" placeholder="https://mails.luckyous.com" />
|
||||||
|
</div>
|
||||||
|
<div class="data-row">
|
||||||
|
<span class="data-label">邮箱类型</span>
|
||||||
|
<select id="select-luckmail-email-type" class="data-select">
|
||||||
|
<option value="self_built">自建邮箱</option>
|
||||||
|
<option value="ms_imap">微软 IMAP</option>
|
||||||
|
<option value="ms_graph">微软 Graph</option>
|
||||||
|
<option value="google_variant">谷歌变种</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="data-row">
|
||||||
|
<span class="data-label">域名</span>
|
||||||
|
<input type="text" id="input-luckmail-domain" class="data-input mono" placeholder="可选,留空则由 LuckMail 默认处理" />
|
||||||
|
</div>
|
||||||
|
<div class="data-row">
|
||||||
|
<span class="data-label">项目</span>
|
||||||
|
<span class="data-value mono">openai</span>
|
||||||
|
</div>
|
||||||
|
<div class="luckmail-manager-card">
|
||||||
|
<div class="section-mini-header">
|
||||||
|
<div class="section-mini-copy">
|
||||||
|
<span class="section-label">LuckMail 邮箱列表</span>
|
||||||
|
<span class="data-value">仅显示项目为 <span class="mono">openai</span> 的邮箱</span>
|
||||||
|
</div>
|
||||||
|
<div class="section-mini-actions">
|
||||||
|
<button id="btn-luckmail-refresh" class="btn btn-ghost btn-xs" type="button">刷新</button>
|
||||||
|
<button id="btn-luckmail-disable-used" class="btn btn-ghost btn-xs" type="button">禁用已用</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="luckmail-summary" class="luckmail-summary">加载已购邮箱后可在这里管理 openai 项目的 LuckMail 邮箱。</div>
|
||||||
|
<div class="luckmail-toolbar">
|
||||||
|
<input id="input-luckmail-search" class="data-input luckmail-search" type="text" placeholder="搜索邮箱 / 标签 / 项目" />
|
||||||
|
<select id="select-luckmail-filter" class="data-select luckmail-filter">
|
||||||
|
<option value="all">全部</option>
|
||||||
|
<option value="reusable">可复用</option>
|
||||||
|
<option value="used">已用</option>
|
||||||
|
<option value="unused">未用</option>
|
||||||
|
<option value="preserved">保留</option>
|
||||||
|
<option value="disabled">已禁用</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="luckmail-bulkbar">
|
||||||
|
<label class="option-toggle luckmail-select-all" for="checkbox-luckmail-select-all">
|
||||||
|
<input type="checkbox" id="checkbox-luckmail-select-all" />
|
||||||
|
<span id="luckmail-selection-summary">已选 0 个</span>
|
||||||
|
</label>
|
||||||
|
<div class="luckmail-bulk-actions">
|
||||||
|
<button id="btn-luckmail-bulk-used" class="btn btn-outline btn-xs" type="button">标记已用</button>
|
||||||
|
<button id="btn-luckmail-bulk-unused" class="btn btn-outline btn-xs" type="button">标记未用</button>
|
||||||
|
<button id="btn-luckmail-bulk-preserve" class="btn btn-outline btn-xs" type="button">保留</button>
|
||||||
|
<button id="btn-luckmail-bulk-unpreserve" class="btn btn-outline btn-xs" type="button">取消保留</button>
|
||||||
|
<button id="btn-luckmail-bulk-disable" class="btn btn-outline btn-xs" type="button">禁用</button>
|
||||||
|
<button id="btn-luckmail-bulk-enable" class="btn btn-outline btn-xs" type="button">启用</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="luckmail-list" class="luckmail-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="icloud-section" class="data-card hotmail-card" style="display:none;">
|
||||||
|
<div class="section-mini-header">
|
||||||
|
<div class="section-mini-copy">
|
||||||
|
<span class="section-label">iCloud 隐私邮箱</span>
|
||||||
|
</div>
|
||||||
|
<div class="section-mini-actions">
|
||||||
|
<button id="btn-icloud-refresh" class="btn btn-ghost btn-xs" type="button">刷新</button>
|
||||||
|
<button id="btn-icloud-delete-used" class="btn btn-ghost btn-xs" type="button">删除已用</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-row">
|
||||||
|
<span class="data-label">iCloud</span>
|
||||||
|
<select id="select-icloud-host-preference" class="data-select">
|
||||||
|
<option value="auto">自动</option>
|
||||||
|
<option value="icloud.com">iCloud.com</option>
|
||||||
|
<option value="icloud.com.cn">iCloud.com.cn</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="data-row">
|
||||||
|
<span class="data-label">自动删除</span>
|
||||||
|
<label class="option-toggle" for="checkbox-auto-delete-icloud">
|
||||||
|
<input type="checkbox" id="checkbox-auto-delete-icloud" />
|
||||||
|
<span>成功使用后自动删除 iCloud 别名</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="icloud-card">
|
||||||
|
<div id="icloud-summary" class="icloud-summary">加载你的 iCloud Hide My Email 别名以便在这里管理。</div>
|
||||||
|
<div id="icloud-login-help" class="icloud-login-help" style="display:none;">
|
||||||
|
<div class="icloud-login-help-main">
|
||||||
|
<div id="icloud-login-help-title" class="icloud-login-help-title">需要登录 iCloud</div>
|
||||||
|
<div id="icloud-login-help-text" class="icloud-login-help-text">我已经为你打开 iCloud 登录页。请在那个页面完成登录,然后回到这里点击“我已登录”。</div>
|
||||||
|
</div>
|
||||||
|
<button id="btn-icloud-login-done" class="btn btn-primary btn-xs" type="button">我已登录</button>
|
||||||
|
</div>
|
||||||
|
<div class="icloud-toolbar">
|
||||||
|
<input id="input-icloud-search" class="data-input icloud-search" type="text" placeholder="搜索邮箱 / 标签 / 备注" />
|
||||||
|
<select id="select-icloud-filter" class="data-select icloud-filter">
|
||||||
|
<option value="all">全部</option>
|
||||||
|
<option value="active">可用</option>
|
||||||
|
<option value="used">已用</option>
|
||||||
|
<option value="unused">未用</option>
|
||||||
|
<option value="preserved">保留</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="icloud-bulkbar">
|
||||||
|
<label class="option-toggle icloud-select-all" for="checkbox-icloud-select-all">
|
||||||
|
<input type="checkbox" id="checkbox-icloud-select-all" />
|
||||||
|
<span id="icloud-selection-summary">已选 0 个</span>
|
||||||
|
</label>
|
||||||
|
<div class="icloud-bulk-actions">
|
||||||
|
<button id="btn-icloud-bulk-used" class="btn btn-outline btn-xs" type="button">标记已用</button>
|
||||||
|
<button id="btn-icloud-bulk-unused" class="btn btn-outline btn-xs" type="button">标记未用</button>
|
||||||
|
<button id="btn-icloud-bulk-preserve" class="btn btn-outline btn-xs" type="button">保留</button>
|
||||||
|
<button id="btn-icloud-bulk-unpreserve" class="btn btn-outline btn-xs" type="button">取消保留</button>
|
||||||
|
<button id="btn-icloud-bulk-delete" class="btn btn-outline btn-xs" type="button">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="icloud-list" class="icloud-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="status-bar" class="status-bar">
|
<div id="status-bar" class="status-bar">
|
||||||
<div class="status-dot"></div>
|
<div class="status-dot"></div>
|
||||||
<span id="display-status">就绪</span>
|
<span id="display-status">就绪</span>
|
||||||
@@ -432,6 +595,7 @@
|
|||||||
<div id="toast-container"></div>
|
<div id="toast-container"></div>
|
||||||
<input id="input-import-settings-file" type="file" accept=".json,application/json" hidden />
|
<input id="input-import-settings-file" type="file" accept=".json,application/json" hidden />
|
||||||
<script src="../hotmail-utils.js"></script>
|
<script src="../hotmail-utils.js"></script>
|
||||||
|
<script src="../luckmail-utils.js"></script>
|
||||||
<script src="update-service.js"></script>
|
<script src="update-service.js"></script>
|
||||||
<script src="sidepanel.js"></script>
|
<script src="sidepanel.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+1520
-25
File diff suppressed because it is too large
Load Diff
@@ -60,11 +60,20 @@ const bundle = [
|
|||||||
extractFunction('hasSavedProgress'),
|
extractFunction('hasSavedProgress'),
|
||||||
extractFunction('getRunningSteps'),
|
extractFunction('getRunningSteps'),
|
||||||
extractFunction('getAutoRunStatusPayload'),
|
extractFunction('getAutoRunStatusPayload'),
|
||||||
|
extractFunction('createAutoRunRoundSummary'),
|
||||||
|
extractFunction('normalizeAutoRunRoundSummary'),
|
||||||
|
extractFunction('buildAutoRunRoundSummaries'),
|
||||||
|
extractFunction('serializeAutoRunRoundSummaries'),
|
||||||
|
extractFunction('getAutoRunRoundRetryCount'),
|
||||||
|
extractFunction('formatAutoRunFailureReasons'),
|
||||||
|
extractFunction('logAutoRunFinalSummary'),
|
||||||
|
extractFunction('waitBetweenAutoRunRounds'),
|
||||||
extractFunction('autoRunLoop'),
|
extractFunction('autoRunLoop'),
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
const api = new Function(`
|
const api = new Function(`
|
||||||
const STOP_ERROR_MESSAGE = 'Flow stopped.';
|
const STOP_ERROR_MESSAGE = 'Flow stopped.';
|
||||||
|
const AUTO_RUN_MAX_RETRIES_PER_ROUND = 3;
|
||||||
const DEFAULT_STATE = {
|
const DEFAULT_STATE = {
|
||||||
stepStatuses: {
|
stepStatuses: {
|
||||||
1: 'pending',
|
1: 'pending',
|
||||||
@@ -180,6 +189,20 @@ function cancelPendingCommands() {}
|
|||||||
function normalizeAutoRunFallbackThreadIntervalMinutes(value) {
|
function normalizeAutoRunFallbackThreadIntervalMinutes(value) {
|
||||||
return Math.max(0, Math.floor(Number(value) || 0));
|
return Math.max(0, Math.floor(Number(value) || 0));
|
||||||
}
|
}
|
||||||
|
function buildAutoRunRoundSummaries(totalRuns, rawSummaries = []) {
|
||||||
|
return Array.from({ length: totalRuns }, (_, index) => ({
|
||||||
|
round: index + 1,
|
||||||
|
status: rawSummaries[index]?.status || 'pending',
|
||||||
|
attempts: rawSummaries[index]?.attempts || 0,
|
||||||
|
failureReasons: [...(rawSummaries[index]?.failureReasons || [])],
|
||||||
|
finalFailureReason: rawSummaries[index]?.finalFailureReason || '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
function serializeAutoRunRoundSummaries(totalRuns, roundSummaries = []) {
|
||||||
|
return buildAutoRunRoundSummaries(totalRuns, roundSummaries);
|
||||||
|
}
|
||||||
|
async function logAutoRunFinalSummary() {}
|
||||||
|
async function waitBetweenAutoRunRounds() {}
|
||||||
|
|
||||||
const chrome = {
|
const chrome = {
|
||||||
runtime: {
|
runtime: {
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const source = fs.readFileSync('background.js', 'utf8');
|
||||||
|
|
||||||
|
function extractFunction(name) {
|
||||||
|
const markers = [`async function ${name}(`, `function ${name}(`];
|
||||||
|
const start = markers
|
||||||
|
.map((marker) => source.indexOf(marker))
|
||||||
|
.find((index) => index >= 0);
|
||||||
|
if (start < 0) {
|
||||||
|
throw new Error(`missing function ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parenDepth = 0;
|
||||||
|
let signatureEnded = false;
|
||||||
|
let braceStart = -1;
|
||||||
|
for (let i = start; i < source.length; i += 1) {
|
||||||
|
const ch = source[i];
|
||||||
|
if (ch === '(') {
|
||||||
|
parenDepth += 1;
|
||||||
|
} else if (ch === ')') {
|
||||||
|
parenDepth -= 1;
|
||||||
|
if (parenDepth === 0) {
|
||||||
|
signatureEnded = true;
|
||||||
|
}
|
||||||
|
} else if (ch === '{' && signatureEnded) {
|
||||||
|
braceStart = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (braceStart < 0) {
|
||||||
|
throw new Error(`missing body for function ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let depth = 0;
|
||||||
|
let end = braceStart;
|
||||||
|
for (; end < source.length; end += 1) {
|
||||||
|
const ch = source[end];
|
||||||
|
if (ch === '{') depth += 1;
|
||||||
|
if (ch === '}') {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) {
|
||||||
|
end += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.slice(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bundle = [
|
||||||
|
extractFunction('normalizeEmailGenerator'),
|
||||||
|
extractFunction('getEmailGeneratorLabel'),
|
||||||
|
extractFunction('normalizePersistentSettingValue'),
|
||||||
|
extractFunction('finalizeIcloudAliasAfterSuccessfulFlow'),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
function createApi(overrides = {}) {
|
||||||
|
return new Function('overrides', `
|
||||||
|
const HOTMAIL_PROVIDER = 'hotmail-api';
|
||||||
|
const HOTMAIL_SERVICE_MODE_LOCAL = 'local';
|
||||||
|
const CLOUDFLARE_TEMP_EMAIL_GENERATOR = 'cloudflare-temp-email';
|
||||||
|
const DEFAULT_LOCAL_CPA_STEP9_MODE = 'submit';
|
||||||
|
const DEFAULT_HOTMAIL_REMOTE_BASE_URL = '';
|
||||||
|
const DEFAULT_HOTMAIL_LOCAL_BASE_URL = 'http://127.0.0.1:17373';
|
||||||
|
const PERSISTED_SETTING_DEFAULTS = {
|
||||||
|
mailProvider: '163',
|
||||||
|
autoStepDelaySeconds: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const calls = {
|
||||||
|
setUsed: [],
|
||||||
|
logs: [],
|
||||||
|
deletes: [],
|
||||||
|
listCalls: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeIcloudHost(value) {
|
||||||
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
|
return ['icloud.com', 'icloud.com.cn'].includes(normalized) ? normalized : '';
|
||||||
|
}
|
||||||
|
function normalizePanelMode(value = '') {
|
||||||
|
return String(value || '').trim().toLowerCase() === 'sub2api' ? 'sub2api' : 'cpa';
|
||||||
|
}
|
||||||
|
function normalizeMailProvider(value = '') {
|
||||||
|
return String(value || '').trim().toLowerCase() || '163';
|
||||||
|
}
|
||||||
|
function normalizeAutoRunFallbackThreadIntervalMinutes(value) {
|
||||||
|
return Math.max(0, Math.floor(Number(value) || 0));
|
||||||
|
}
|
||||||
|
function normalizeAutoRunDelayMinutes(value) {
|
||||||
|
return Math.max(1, Math.floor(Number(value) || 30));
|
||||||
|
}
|
||||||
|
function normalizeAutoStepDelaySeconds(value, fallback = null) {
|
||||||
|
const numeric = Number(value);
|
||||||
|
return Number.isFinite(numeric) ? Math.max(0, Math.floor(numeric)) : fallback;
|
||||||
|
}
|
||||||
|
function normalizeHotmailServiceMode() {
|
||||||
|
return HOTMAIL_SERVICE_MODE_LOCAL;
|
||||||
|
}
|
||||||
|
function normalizeHotmailRemoteBaseUrl(value = '') {
|
||||||
|
return String(value || '').trim() || DEFAULT_HOTMAIL_REMOTE_BASE_URL;
|
||||||
|
}
|
||||||
|
function normalizeHotmailLocalBaseUrl(value = '') {
|
||||||
|
return String(value || '').trim() || DEFAULT_HOTMAIL_LOCAL_BASE_URL;
|
||||||
|
}
|
||||||
|
function normalizeCloudflareDomain(value = '') {
|
||||||
|
return String(value || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
function normalizeCloudflareDomains(values = []) {
|
||||||
|
return Array.isArray(values) ? values : [];
|
||||||
|
}
|
||||||
|
function normalizeHotmailAccounts(values = []) {
|
||||||
|
return Array.isArray(values) ? values : [];
|
||||||
|
}
|
||||||
|
function getManualAliasUsageMap(state) {
|
||||||
|
return { ...(state?.manualAliasUsage || {}) };
|
||||||
|
}
|
||||||
|
function getPreservedAliasMap(state) {
|
||||||
|
return { ...(state?.preservedAliases || {}) };
|
||||||
|
}
|
||||||
|
function isAliasPreserved(state, email) {
|
||||||
|
return Boolean(getPreservedAliasMap(state)[String(email || '').trim().toLowerCase()]);
|
||||||
|
}
|
||||||
|
async function setIcloudAliasUsedState(payload, options = {}) {
|
||||||
|
calls.setUsed.push({ payload, options });
|
||||||
|
}
|
||||||
|
async function addLog(message, level = 'info') {
|
||||||
|
calls.logs.push({ message, level });
|
||||||
|
}
|
||||||
|
async function deleteIcloudAlias(alias) {
|
||||||
|
calls.deletes.push(alias);
|
||||||
|
}
|
||||||
|
async function listIcloudAliases() {
|
||||||
|
calls.listCalls += 1;
|
||||||
|
return overrides.listIcloudAliases ? overrides.listIcloudAliases() : [];
|
||||||
|
}
|
||||||
|
function findIcloudAliasByEmail(aliases, email) {
|
||||||
|
return (aliases || []).find((alias) => String(alias.email || '').toLowerCase() === String(email || '').toLowerCase()) || null;
|
||||||
|
}
|
||||||
|
function getErrorMessage(error) {
|
||||||
|
return String(typeof error === 'string' ? error : error?.message || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
${bundle}
|
||||||
|
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
normalizeEmailGenerator,
|
||||||
|
getEmailGeneratorLabel,
|
||||||
|
normalizePersistentSettingValue,
|
||||||
|
finalizeIcloudAliasAfterSuccessfulFlow,
|
||||||
|
};
|
||||||
|
`)(overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('normalizeEmailGenerator and label support icloud', () => {
|
||||||
|
const api = createApi();
|
||||||
|
assert.equal(api.normalizeEmailGenerator('icloud'), 'icloud');
|
||||||
|
assert.equal(api.getEmailGeneratorLabel('icloud'), 'iCloud 隐私邮箱');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizePersistentSettingValue handles icloud settings', () => {
|
||||||
|
const api = createApi();
|
||||||
|
assert.equal(api.normalizePersistentSettingValue('icloudHostPreference', 'icloud.com'), 'icloud.com');
|
||||||
|
assert.equal(api.normalizePersistentSettingValue('icloudHostPreference', 'bad-host'), 'auto');
|
||||||
|
assert.equal(api.normalizePersistentSettingValue('autoDeleteUsedIcloudAlias', 1), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finalizeIcloudAliasAfterSuccessfulFlow marks icloud aliases as used without deleting when auto-delete is off', async () => {
|
||||||
|
const api = createApi();
|
||||||
|
const result = await api.finalizeIcloudAliasAfterSuccessfulFlow({
|
||||||
|
email: 'alias@icloud.com',
|
||||||
|
emailGenerator: 'icloud',
|
||||||
|
autoDeleteUsedIcloudAlias: false,
|
||||||
|
manualAliasUsage: {},
|
||||||
|
preservedAliases: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, { handled: true, deleted: false });
|
||||||
|
assert.equal(api.calls.setUsed.length, 1);
|
||||||
|
assert.equal(api.calls.listCalls, 0);
|
||||||
|
assert.equal(api.calls.deletes.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finalizeIcloudAliasAfterSuccessfulFlow skips deleting preserved aliases', async () => {
|
||||||
|
const api = createApi();
|
||||||
|
const result = await api.finalizeIcloudAliasAfterSuccessfulFlow({
|
||||||
|
email: 'alias@icloud.com',
|
||||||
|
emailGenerator: 'icloud',
|
||||||
|
autoDeleteUsedIcloudAlias: true,
|
||||||
|
manualAliasUsage: {},
|
||||||
|
preservedAliases: { 'alias@icloud.com': true },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, { handled: true, deleted: false });
|
||||||
|
assert.equal(api.calls.setUsed.length, 1);
|
||||||
|
assert.equal(api.calls.listCalls, 0);
|
||||||
|
assert.equal(api.calls.deletes.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finalizeIcloudAliasAfterSuccessfulFlow skips deleting aliases that are preserved in the latest alias list', async () => {
|
||||||
|
const api = createApi({
|
||||||
|
listIcloudAliases() {
|
||||||
|
return [
|
||||||
|
{ email: 'alias@icloud.com', anonymousId: 'anon-1', preserved: true },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await api.finalizeIcloudAliasAfterSuccessfulFlow({
|
||||||
|
email: 'alias@icloud.com',
|
||||||
|
emailGenerator: 'icloud',
|
||||||
|
autoDeleteUsedIcloudAlias: true,
|
||||||
|
manualAliasUsage: {},
|
||||||
|
preservedAliases: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, { handled: true, deleted: false });
|
||||||
|
assert.equal(api.calls.setUsed.length, 1);
|
||||||
|
assert.equal(api.calls.listCalls, 1);
|
||||||
|
assert.equal(api.calls.deletes.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finalizeIcloudAliasAfterSuccessfulFlow deletes alias when auto-delete is enabled and alias exists', async () => {
|
||||||
|
const api = createApi({
|
||||||
|
listIcloudAliases() {
|
||||||
|
return [
|
||||||
|
{ email: 'alias@icloud.com', anonymousId: 'anon-1', preserved: false },
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await api.finalizeIcloudAliasAfterSuccessfulFlow({
|
||||||
|
email: 'alias@icloud.com',
|
||||||
|
emailGenerator: 'icloud',
|
||||||
|
autoDeleteUsedIcloudAlias: true,
|
||||||
|
manualAliasUsage: {},
|
||||||
|
preservedAliases: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, { handled: true, deleted: true });
|
||||||
|
assert.equal(api.calls.setUsed.length, 1);
|
||||||
|
assert.equal(api.calls.listCalls, 1);
|
||||||
|
assert.deepEqual(api.calls.deletes, [
|
||||||
|
{ email: 'alias@icloud.com', anonymousId: 'anon-1', preserved: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finalizeIcloudAliasAfterSuccessfulFlow ignores non-icloud flows', async () => {
|
||||||
|
const api = createApi();
|
||||||
|
const result = await api.finalizeIcloudAliasAfterSuccessfulFlow({
|
||||||
|
email: 'plain@example.com',
|
||||||
|
emailGenerator: 'duck',
|
||||||
|
autoDeleteUsedIcloudAlias: true,
|
||||||
|
manualAliasUsage: {},
|
||||||
|
preservedAliases: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, { handled: false, deleted: false });
|
||||||
|
assert.equal(api.calls.setUsed.length, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,572 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
|
||||||
|
const source = fs.readFileSync('background.js', 'utf8');
|
||||||
|
|
||||||
|
function extractFunction(name) {
|
||||||
|
const markers = [`async function ${name}(`, `function ${name}(`];
|
||||||
|
const start = markers
|
||||||
|
.map((marker) => source.indexOf(marker))
|
||||||
|
.find((index) => index >= 0);
|
||||||
|
if (start < 0) {
|
||||||
|
throw new Error(`missing function ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parenDepth = 0;
|
||||||
|
let signatureEnded = false;
|
||||||
|
let braceStart = -1;
|
||||||
|
for (let i = start; i < source.length; i += 1) {
|
||||||
|
const ch = source[i];
|
||||||
|
if (ch === '(') {
|
||||||
|
parenDepth += 1;
|
||||||
|
} else if (ch === ')') {
|
||||||
|
parenDepth -= 1;
|
||||||
|
if (parenDepth === 0) {
|
||||||
|
signatureEnded = true;
|
||||||
|
}
|
||||||
|
} else if (ch === '{' && signatureEnded) {
|
||||||
|
braceStart = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (braceStart < 0) {
|
||||||
|
throw new Error(`missing body for function ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let depth = 0;
|
||||||
|
let end = braceStart;
|
||||||
|
for (; end < source.length; end += 1) {
|
||||||
|
const ch = source[end];
|
||||||
|
if (ch === '{') depth += 1;
|
||||||
|
if (ch === '}') {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) {
|
||||||
|
end += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.slice(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('ensureLuckmailPurchaseForFlow buys openai mailbox and defaults email type to ms_graph', async () => {
|
||||||
|
const bundle = [
|
||||||
|
extractFunction('getLuckmailSessionConfig'),
|
||||||
|
extractFunction('getCurrentLuckmailPurchase'),
|
||||||
|
extractFunction('ensureLuckmailPurchaseForFlow'),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const factory = new Function('initialState', `
|
||||||
|
let currentState = { ...initialState };
|
||||||
|
const DEFAULT_LUCKMAIL_PROJECT_CODE = 'openai';
|
||||||
|
const purchaseCalls = [];
|
||||||
|
const activateCalls = [];
|
||||||
|
|
||||||
|
function normalizeLuckmailBaseUrl(value) {
|
||||||
|
return String(value || '').trim() || 'https://mails.luckyous.com';
|
||||||
|
}
|
||||||
|
function normalizeLuckmailEmailType(value) {
|
||||||
|
return ['self_built', 'ms_imap', 'ms_graph', 'google_variant'].includes(String(value || '').trim())
|
||||||
|
? String(value || '').trim()
|
||||||
|
: 'ms_graph';
|
||||||
|
}
|
||||||
|
function normalizeLuckmailPurchase(value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function normalizeLuckmailPurchases(value) {
|
||||||
|
return value.purchases || [];
|
||||||
|
}
|
||||||
|
async function getState() {
|
||||||
|
return currentState;
|
||||||
|
}
|
||||||
|
function createLuckmailClient() {
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
async purchaseEmails(projectCode, quantity, options) {
|
||||||
|
purchaseCalls.push({ projectCode, quantity, options });
|
||||||
|
return {
|
||||||
|
purchases: [{ id: 15, email_address: 'demo@outlook.com', token: 'tok-1' }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function findReusableLuckmailPurchaseForFlow() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
async function activateLuckmailPurchaseForFlow(state, client, purchase, options) {
|
||||||
|
activateCalls.push({ state, purchase, options });
|
||||||
|
currentState.currentLuckmailPurchase = purchase;
|
||||||
|
currentState.email = purchase.email_address;
|
||||||
|
return purchase;
|
||||||
|
}
|
||||||
|
|
||||||
|
${bundle}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ensureLuckmailPurchaseForFlow,
|
||||||
|
snapshot() {
|
||||||
|
return { currentState, purchaseCalls, activateCalls };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
`);
|
||||||
|
|
||||||
|
const api = factory({
|
||||||
|
luckmailApiKey: 'sk-test',
|
||||||
|
luckmailBaseUrl: '',
|
||||||
|
luckmailEmailType: '',
|
||||||
|
luckmailDomain: '',
|
||||||
|
currentLuckmailPurchase: null,
|
||||||
|
email: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const purchase = await api.ensureLuckmailPurchaseForFlow();
|
||||||
|
const snapshot = api.snapshot();
|
||||||
|
|
||||||
|
assert.equal(purchase.email_address, 'demo@outlook.com');
|
||||||
|
assert.deepStrictEqual(snapshot.purchaseCalls, [{
|
||||||
|
projectCode: 'openai',
|
||||||
|
quantity: 1,
|
||||||
|
options: {
|
||||||
|
emailType: 'ms_graph',
|
||||||
|
domain: undefined,
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
assert.equal(snapshot.activateCalls[0].options.initializeCursor, false);
|
||||||
|
assert.equal(snapshot.currentState.email, 'demo@outlook.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ensureLuckmailPurchaseForFlow reuses reusable openai mailbox before buying a new one', async () => {
|
||||||
|
const bundle = [
|
||||||
|
extractFunction('getLuckmailSessionConfig'),
|
||||||
|
extractFunction('getCurrentLuckmailPurchase'),
|
||||||
|
extractFunction('ensureLuckmailPurchaseForFlow'),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const factory = new Function('initialState', `
|
||||||
|
let currentState = { ...initialState };
|
||||||
|
const DEFAULT_LUCKMAIL_PROJECT_CODE = 'openai';
|
||||||
|
const purchaseCalls = [];
|
||||||
|
const activateCalls = [];
|
||||||
|
|
||||||
|
function normalizeLuckmailBaseUrl(value) {
|
||||||
|
return String(value || '').trim() || 'https://mails.luckyous.com';
|
||||||
|
}
|
||||||
|
function normalizeLuckmailEmailType(value) {
|
||||||
|
return ['self_built', 'ms_imap', 'ms_graph', 'google_variant'].includes(String(value || '').trim())
|
||||||
|
? String(value || '').trim()
|
||||||
|
: 'ms_graph';
|
||||||
|
}
|
||||||
|
function normalizeLuckmailPurchase(value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
function normalizeLuckmailPurchases(value) {
|
||||||
|
return value.purchases || [];
|
||||||
|
}
|
||||||
|
async function getState() {
|
||||||
|
return currentState;
|
||||||
|
}
|
||||||
|
function createLuckmailClient() {
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
async purchaseEmails(projectCode, quantity, options) {
|
||||||
|
purchaseCalls.push({ projectCode, quantity, options });
|
||||||
|
return { purchases: [] };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function findReusableLuckmailPurchaseForFlow() {
|
||||||
|
return {
|
||||||
|
id: 99,
|
||||||
|
email_address: 'reuse@outlook.com',
|
||||||
|
token: 'tok-reuse',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function activateLuckmailPurchaseForFlow(state, client, purchase, options) {
|
||||||
|
activateCalls.push({ state, purchase, options });
|
||||||
|
currentState.currentLuckmailPurchase = purchase;
|
||||||
|
currentState.email = purchase.email_address;
|
||||||
|
return purchase;
|
||||||
|
}
|
||||||
|
|
||||||
|
${bundle}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ensureLuckmailPurchaseForFlow,
|
||||||
|
snapshot() {
|
||||||
|
return { currentState, purchaseCalls, activateCalls };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
`);
|
||||||
|
|
||||||
|
const api = factory({
|
||||||
|
luckmailApiKey: 'sk-test',
|
||||||
|
luckmailBaseUrl: 'https://mails.luckyous.com',
|
||||||
|
luckmailEmailType: 'ms_imap',
|
||||||
|
luckmailDomain: 'outlook.com',
|
||||||
|
currentLuckmailPurchase: null,
|
||||||
|
email: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const purchase = await api.ensureLuckmailPurchaseForFlow();
|
||||||
|
const snapshot = api.snapshot();
|
||||||
|
|
||||||
|
assert.equal(purchase.id, 99);
|
||||||
|
assert.deepStrictEqual(snapshot.purchaseCalls, []);
|
||||||
|
assert.equal(snapshot.activateCalls[0].options.initializeCursor, true);
|
||||||
|
assert.match(snapshot.activateCalls[0].options.logMessage, /已复用 openai 邮箱/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activateLuckmailPurchaseForFlow builds baseline cursor from existing mails when reusing mailbox', async () => {
|
||||||
|
const bundle = extractFunction('activateLuckmailPurchaseForFlow');
|
||||||
|
|
||||||
|
const factory = new Function(`
|
||||||
|
let currentPurchase = null;
|
||||||
|
let currentCursor = null;
|
||||||
|
let currentEmail = null;
|
||||||
|
const buildCalls = [];
|
||||||
|
|
||||||
|
function normalizeLuckmailPurchase(value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
async function setLuckmailPurchaseState(value) {
|
||||||
|
currentPurchase = value;
|
||||||
|
}
|
||||||
|
async function setLuckmailMailCursorState(value) {
|
||||||
|
currentCursor = value;
|
||||||
|
}
|
||||||
|
async function setEmailState(value) {
|
||||||
|
currentEmail = value;
|
||||||
|
}
|
||||||
|
async function addLog() {}
|
||||||
|
function buildLuckmailBaselineCursor(mails) {
|
||||||
|
buildCalls.push(mails);
|
||||||
|
return { messageId: 'mail-new', receivedAt: '2026-04-14 13:32:05' };
|
||||||
|
}
|
||||||
|
|
||||||
|
${bundle}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activateLuckmailPurchaseForFlow,
|
||||||
|
snapshot() {
|
||||||
|
return { currentPurchase, currentCursor, currentEmail, buildCalls };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
`);
|
||||||
|
|
||||||
|
const api = factory();
|
||||||
|
const client = {
|
||||||
|
user: {
|
||||||
|
async getTokenMails() {
|
||||||
|
return {
|
||||||
|
mails: [
|
||||||
|
{ message_id: 'mail-old', received_at: '2026-04-14 13:31:15' },
|
||||||
|
{ message_id: 'mail-new', received_at: '2026-04-14 13:32:05' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await api.activateLuckmailPurchaseForFlow({}, client, {
|
||||||
|
id: 5,
|
||||||
|
email_address: 'reuse@outlook.com',
|
||||||
|
token: 'tok-reuse',
|
||||||
|
}, {
|
||||||
|
initializeCursor: true,
|
||||||
|
logMessage: 'reuse',
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = api.snapshot();
|
||||||
|
assert.equal(snapshot.currentPurchase.id, 5);
|
||||||
|
assert.deepStrictEqual(snapshot.currentCursor, {
|
||||||
|
messageId: 'mail-new',
|
||||||
|
receivedAt: '2026-04-14 13:32:05',
|
||||||
|
});
|
||||||
|
assert.equal(snapshot.currentEmail, 'reuse@outlook.com');
|
||||||
|
assert.equal(snapshot.buildCalls.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listLuckmailPurchasesByProject only keeps openai purchases', async () => {
|
||||||
|
const bundle = extractFunction('listLuckmailPurchasesByProject');
|
||||||
|
|
||||||
|
const factory = new Function(`
|
||||||
|
const DEFAULT_LUCKMAIL_PROJECT_CODE = 'openai';
|
||||||
|
function normalizeLuckmailProjectName(value) {
|
||||||
|
return String(value || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
function isLuckmailPurchaseForProject(purchase, projectCode) {
|
||||||
|
return normalizeLuckmailProjectName(purchase.project_name || purchase.project) === normalizeLuckmailProjectName(projectCode);
|
||||||
|
}
|
||||||
|
async function getAllLuckmailPurchases() {
|
||||||
|
return [
|
||||||
|
{ id: 1, project_name: 'OpenAi' },
|
||||||
|
{ id: 2, project_name: 'other' },
|
||||||
|
{ id: 3, project: 'openai' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
${bundle}
|
||||||
|
|
||||||
|
return { listLuckmailPurchasesByProject };
|
||||||
|
`);
|
||||||
|
|
||||||
|
const api = factory();
|
||||||
|
const result = await api.listLuckmailPurchasesByProject({}, { projectCode: 'openai' });
|
||||||
|
assert.deepStrictEqual(result.map((item) => item.id), [1, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disableUsedLuckmailPurchases only disables locally used and non-preserved openai mailboxes', async () => {
|
||||||
|
const bundle = extractFunction('disableUsedLuckmailPurchases');
|
||||||
|
|
||||||
|
const factory = new Function(`
|
||||||
|
let clearedOptions = null;
|
||||||
|
const disabledCalls = [];
|
||||||
|
const DEFAULT_LUCKMAIL_PROJECT_CODE = 'openai';
|
||||||
|
|
||||||
|
function normalizeLuckmailPurchaseId(value) {
|
||||||
|
const numeric = Number(value);
|
||||||
|
return Number.isFinite(numeric) && numeric > 0 ? String(Math.floor(numeric)) : '';
|
||||||
|
}
|
||||||
|
async function ensureManualInteractionAllowed() {
|
||||||
|
return {
|
||||||
|
luckmailUsedPurchases: { 1: true, 2: true, 3: true },
|
||||||
|
luckmailPreserveTagId: 9,
|
||||||
|
luckmailPreserveTagName: '保留',
|
||||||
|
mailProvider: 'luckmail-api',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function getLuckmailUsedPurchases(state) {
|
||||||
|
return state.luckmailUsedPurchases;
|
||||||
|
}
|
||||||
|
function getLuckmailPreserveTagInfo(state) {
|
||||||
|
return {
|
||||||
|
id: state.luckmailPreserveTagId,
|
||||||
|
name: state.luckmailPreserveTagName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function isLuckmailPurchasePreserved(purchase, options) {
|
||||||
|
return purchase.tag_id === options.preserveTagId || purchase.tag_name === options.preserveTagName;
|
||||||
|
}
|
||||||
|
function createLuckmailClient() {
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
async batchSetPurchaseDisabled(ids, disabled) {
|
||||||
|
disabledCalls.push({ ids, disabled });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function listLuckmailPurchasesByProject() {
|
||||||
|
return [
|
||||||
|
{ id: 1, email_address: 'used-1@outlook.com', user_disabled: 0, tag_id: 0, tag_name: '' },
|
||||||
|
{ id: 2, email_address: 'preserved@outlook.com', user_disabled: 0, tag_id: 9, tag_name: '保留' },
|
||||||
|
{ id: 3, email_address: 'already-disabled@outlook.com', user_disabled: 1, tag_id: 0, tag_name: '' },
|
||||||
|
{ id: 4, email_address: 'unused@outlook.com', user_disabled: 0, tag_id: 0, tag_name: '' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
async function getState() {
|
||||||
|
return {
|
||||||
|
currentLuckmailPurchase: { id: 1 },
|
||||||
|
mailProvider: 'luckmail-api',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function getCurrentLuckmailPurchase(state) {
|
||||||
|
return state.currentLuckmailPurchase;
|
||||||
|
}
|
||||||
|
function isLuckmailProvider(state) {
|
||||||
|
return state.mailProvider === 'luckmail-api';
|
||||||
|
}
|
||||||
|
async function clearLuckmailRuntimeState(options) {
|
||||||
|
clearedOptions = options;
|
||||||
|
}
|
||||||
|
async function addLog() {}
|
||||||
|
|
||||||
|
${bundle}
|
||||||
|
|
||||||
|
return {
|
||||||
|
disableUsedLuckmailPurchases,
|
||||||
|
snapshot() {
|
||||||
|
return { disabledCalls, clearedOptions };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
`);
|
||||||
|
|
||||||
|
const api = factory();
|
||||||
|
const result = await api.disableUsedLuckmailPurchases();
|
||||||
|
const snapshot = api.snapshot();
|
||||||
|
|
||||||
|
assert.deepStrictEqual(result.disabledIds, [1]);
|
||||||
|
assert.deepStrictEqual(snapshot.disabledCalls, [{ ids: [1], disabled: 1 }]);
|
||||||
|
assert.deepStrictEqual(snapshot.clearedOptions, { clearEmail: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resetState preserves LuckMail session config, used map, and preserve tag cache while clearing runtime purchase state', async () => {
|
||||||
|
const bundle = extractFunction('resetState');
|
||||||
|
|
||||||
|
const factory = new Function([
|
||||||
|
'let cleared = false;',
|
||||||
|
'let storedPayload = null;',
|
||||||
|
"const LOG_PREFIX = '[test]';",
|
||||||
|
"const DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME = '保留';",
|
||||||
|
'const DEFAULT_STATE = {',
|
||||||
|
" luckmailApiKey: '',",
|
||||||
|
" luckmailBaseUrl: 'https://mails.luckyous.com',",
|
||||||
|
" luckmailEmailType: 'ms_graph',",
|
||||||
|
" luckmailDomain: '',",
|
||||||
|
' luckmailUsedPurchases: {},',
|
||||||
|
' luckmailPreserveTagId: 0,',
|
||||||
|
" luckmailPreserveTagName: '保留',",
|
||||||
|
" currentLuckmailPurchase: { token: 'stale' },",
|
||||||
|
" currentLuckmailMailCursor: { messageId: 'stale' },",
|
||||||
|
' email: null,',
|
||||||
|
'};',
|
||||||
|
'function normalizeLuckmailBaseUrl(value) {',
|
||||||
|
" const normalized = String(value || '').trim() || 'https://mails.luckyous.com';",
|
||||||
|
" return normalized.replace(/\\/$/, '');",
|
||||||
|
'}',
|
||||||
|
'function normalizeLuckmailEmailType(value) {',
|
||||||
|
" return ['self_built', 'ms_imap', 'ms_graph', 'google_variant'].includes(String(value || '').trim())",
|
||||||
|
" ? String(value || '').trim()",
|
||||||
|
" : 'ms_graph';",
|
||||||
|
'}',
|
||||||
|
'function normalizeLuckmailUsedPurchases(value) {',
|
||||||
|
' return value || {};',
|
||||||
|
'}',
|
||||||
|
'async function getPersistedSettings() {',
|
||||||
|
" return { mailProvider: '163' };",
|
||||||
|
'}',
|
||||||
|
'async function getPersistedAliasState() {',
|
||||||
|
' return {};',
|
||||||
|
'}',
|
||||||
|
'const chrome = {',
|
||||||
|
' storage: {',
|
||||||
|
' session: {',
|
||||||
|
' async get() {',
|
||||||
|
' return {',
|
||||||
|
" seenCodes: ['seen-1'],",
|
||||||
|
" seenInbucketMailIds: ['mail-1'],",
|
||||||
|
" accounts: [{ email: 'saved@example.com' }],",
|
||||||
|
" tabRegistry: { foo: { tabId: 1 } },",
|
||||||
|
" sourceLastUrls: { foo: 'https://example.com' },",
|
||||||
|
" luckmailApiKey: 'sk-session',",
|
||||||
|
" luckmailBaseUrl: 'https://demo.example.com/',",
|
||||||
|
" luckmailEmailType: 'ms_imap',",
|
||||||
|
" luckmailDomain: 'outlook.com',",
|
||||||
|
" luckmailUsedPurchases: { 88: true },",
|
||||||
|
' luckmailPreserveTagId: 9,',
|
||||||
|
" luckmailPreserveTagName: '保留',",
|
||||||
|
' };',
|
||||||
|
' },',
|
||||||
|
' async clear() {',
|
||||||
|
' cleared = true;',
|
||||||
|
' },',
|
||||||
|
' async set(payload) {',
|
||||||
|
' storedPayload = payload;',
|
||||||
|
' },',
|
||||||
|
' },',
|
||||||
|
' },',
|
||||||
|
'};',
|
||||||
|
bundle,
|
||||||
|
'return {',
|
||||||
|
' resetState,',
|
||||||
|
' snapshot() {',
|
||||||
|
' return { cleared, storedPayload };',
|
||||||
|
' },',
|
||||||
|
'};',
|
||||||
|
].join('\n'));
|
||||||
|
|
||||||
|
const api = factory();
|
||||||
|
await api.resetState();
|
||||||
|
const snapshot = api.snapshot();
|
||||||
|
|
||||||
|
assert.equal(snapshot.cleared, true);
|
||||||
|
assert.equal(snapshot.storedPayload.luckmailApiKey, 'sk-session');
|
||||||
|
assert.equal(snapshot.storedPayload.luckmailBaseUrl, 'https://demo.example.com');
|
||||||
|
assert.equal(snapshot.storedPayload.luckmailEmailType, 'ms_imap');
|
||||||
|
assert.equal(snapshot.storedPayload.luckmailDomain, 'outlook.com');
|
||||||
|
assert.deepStrictEqual(snapshot.storedPayload.luckmailUsedPurchases, { 88: true });
|
||||||
|
assert.equal(snapshot.storedPayload.luckmailPreserveTagId, 9);
|
||||||
|
assert.equal(snapshot.storedPayload.luckmailPreserveTagName, '保留');
|
||||||
|
assert.equal(snapshot.storedPayload.currentLuckmailPurchase, null);
|
||||||
|
assert.equal(snapshot.storedPayload.currentLuckmailMailCursor, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleStepData step 9 marks current LuckMail purchase as used and clears runtime state', async () => {
|
||||||
|
const bundle = extractFunction('handleStepData');
|
||||||
|
|
||||||
|
const factory = new Function(`
|
||||||
|
let clearedOptions = null;
|
||||||
|
let usedMarker = null;
|
||||||
|
const logs = [];
|
||||||
|
|
||||||
|
async function closeLocalhostCallbackTabs() {}
|
||||||
|
async function getState() {
|
||||||
|
return {
|
||||||
|
mailProvider: 'luckmail-api',
|
||||||
|
currentHotmailAccountId: null,
|
||||||
|
currentLuckmailPurchase: {
|
||||||
|
id: 123,
|
||||||
|
email_address: 'demo@outlook.com',
|
||||||
|
},
|
||||||
|
email: 'demo@outlook.com',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function getCurrentLuckmailPurchase(state) {
|
||||||
|
return state.currentLuckmailPurchase;
|
||||||
|
}
|
||||||
|
function isHotmailProvider() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
async function patchHotmailAccount() {}
|
||||||
|
function isLuckmailProvider(state) {
|
||||||
|
return state.mailProvider === 'luckmail-api';
|
||||||
|
}
|
||||||
|
async function setLuckmailPurchaseUsedState(purchaseId, used) {
|
||||||
|
usedMarker = { purchaseId, used };
|
||||||
|
}
|
||||||
|
async function clearLuckmailRuntimeState(options) {
|
||||||
|
clearedOptions = options;
|
||||||
|
}
|
||||||
|
async function addLog(message, level) {
|
||||||
|
logs.push({ message, level });
|
||||||
|
}
|
||||||
|
function buildLocalhostCleanupPrefix() {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
async function closeTabsByUrlPrefix() {}
|
||||||
|
function shouldUseCustomRegistrationEmail() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
async function setEmailStateSilently() {}
|
||||||
|
async function setState() {}
|
||||||
|
function broadcastDataUpdate() {}
|
||||||
|
function isLocalhostOAuthCallbackUrl() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
async function finalizeIcloudAliasAfterSuccessfulFlow() {}
|
||||||
|
|
||||||
|
${bundle}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleStepData,
|
||||||
|
snapshot() {
|
||||||
|
return { clearedOptions, usedMarker, logs };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
`);
|
||||||
|
|
||||||
|
const api = factory();
|
||||||
|
await api.handleStepData(9, {
|
||||||
|
localhostUrl: 'http://localhost:1455/auth/callback?code=abc&state=xyz',
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = api.snapshot();
|
||||||
|
assert.deepStrictEqual(snapshot.usedMarker, { purchaseId: 123, used: true });
|
||||||
|
assert.deepStrictEqual(snapshot.clearedOptions, { clearEmail: true });
|
||||||
|
assert.equal(snapshot.logs.at(-1).message, '当前 LuckMail 邮箱运行态已清空,下轮将优先复用未用邮箱或重新购买邮箱。');
|
||||||
|
});
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const source = fs.readFileSync('background.js', 'utf8');
|
||||||
|
|
||||||
|
function extractFunction(name) {
|
||||||
|
const markers = [`async function ${name}(`, `function ${name}(`];
|
||||||
|
const start = markers
|
||||||
|
.map((marker) => source.indexOf(marker))
|
||||||
|
.find((index) => index >= 0);
|
||||||
|
if (start < 0) {
|
||||||
|
throw new Error(`missing function ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parenDepth = 0;
|
||||||
|
let signatureEnded = false;
|
||||||
|
let braceStart = -1;
|
||||||
|
for (let i = start; i < source.length; i += 1) {
|
||||||
|
const ch = source[i];
|
||||||
|
if (ch === '(') {
|
||||||
|
parenDepth += 1;
|
||||||
|
} else if (ch === ')') {
|
||||||
|
parenDepth -= 1;
|
||||||
|
if (parenDepth === 0) {
|
||||||
|
signatureEnded = true;
|
||||||
|
}
|
||||||
|
} else if (ch === '{' && signatureEnded) {
|
||||||
|
braceStart = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (braceStart < 0) {
|
||||||
|
throw new Error(`missing body for function ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let depth = 0;
|
||||||
|
let end = braceStart;
|
||||||
|
for (; end < source.length; end += 1) {
|
||||||
|
const ch = source[end];
|
||||||
|
if (ch === '{') depth += 1;
|
||||||
|
if (ch === '}') {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) {
|
||||||
|
end += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.slice(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('pollCloudflareTempEmailVerificationCode returns code even if delete fails', async () => {
|
||||||
|
const bundle = [
|
||||||
|
extractFunction('isStopError'),
|
||||||
|
extractFunction('throwIfStopped'),
|
||||||
|
extractFunction('summarizeCloudflareTempEmailMessagesForLog'),
|
||||||
|
extractFunction('pollCloudflareTempEmailVerificationCode'),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const api = new Function(`
|
||||||
|
let stopRequested = false;
|
||||||
|
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
|
||||||
|
const CLOUDFLARE_TEMP_EMAIL_DEFAULT_PAGE_SIZE = 20;
|
||||||
|
const logs = [];
|
||||||
|
function normalizeCloudflareTempEmailAddress(value) {
|
||||||
|
return String(value || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addLog(message, level) {
|
||||||
|
logs.push({ message, level });
|
||||||
|
}
|
||||||
|
async function sleepWithStop() {}
|
||||||
|
async function listCloudflareTempEmailMessages() {
|
||||||
|
return {
|
||||||
|
config: {},
|
||||||
|
messages: [{
|
||||||
|
id: 'mail-1',
|
||||||
|
address: 'user@example.com',
|
||||||
|
receivedDateTime: '2026-04-13T09:20:00.000Z',
|
||||||
|
subject: 'OpenAI verification code',
|
||||||
|
from: { emailAddress: { address: 'noreply@tm.openai.com' } },
|
||||||
|
bodyPreview: 'Your verification code is 123456.',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function pickVerificationMessageWithTimeFallback(messages) {
|
||||||
|
return {
|
||||||
|
match: {
|
||||||
|
code: '123456',
|
||||||
|
receivedAt: Date.parse(messages[0].receivedDateTime),
|
||||||
|
message: messages[0],
|
||||||
|
},
|
||||||
|
usedRelaxedFilters: false,
|
||||||
|
usedTimeFallback: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function deleteCloudflareTempEmailMail() {
|
||||||
|
throw new Error('delete failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
${bundle}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pollCloudflareTempEmailVerificationCode,
|
||||||
|
snapshot() {
|
||||||
|
return { logs };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
`)();
|
||||||
|
|
||||||
|
const result = await api.pollCloudflareTempEmailVerificationCode(4, { email: 'user@example.com' }, {
|
||||||
|
targetEmail: 'user@example.com',
|
||||||
|
maxAttempts: 1,
|
||||||
|
intervalMs: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.code, '123456');
|
||||||
|
const state = api.snapshot();
|
||||||
|
assert.equal(state.logs.some((entry) => entry.message.includes('删除 Cloudflare Temp Email 邮件失败')), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pollCloudflareTempEmailVerificationCode requires target email', async () => {
|
||||||
|
const bundle = [
|
||||||
|
extractFunction('isStopError'),
|
||||||
|
extractFunction('throwIfStopped'),
|
||||||
|
extractFunction('pollCloudflareTempEmailVerificationCode'),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const api = new Function(`
|
||||||
|
let stopRequested = false;
|
||||||
|
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
|
||||||
|
const CLOUDFLARE_TEMP_EMAIL_DEFAULT_PAGE_SIZE = 20;
|
||||||
|
function normalizeCloudflareTempEmailAddress(value) {
|
||||||
|
return String(value || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
async function addLog() {}
|
||||||
|
async function sleepWithStop() {}
|
||||||
|
async function listCloudflareTempEmailMessages() {
|
||||||
|
throw new Error('should not reach list');
|
||||||
|
}
|
||||||
|
function pickVerificationMessageWithTimeFallback() {
|
||||||
|
return { match: null, usedRelaxedFilters: false, usedTimeFallback: false };
|
||||||
|
}
|
||||||
|
async function deleteCloudflareTempEmailMail() {}
|
||||||
|
function summarizeCloudflareTempEmailMessagesForLog() {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
${bundle}
|
||||||
|
|
||||||
|
return { pollCloudflareTempEmailVerificationCode };
|
||||||
|
`)();
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
api.pollCloudflareTempEmailVerificationCode(4, {}, {}),
|
||||||
|
/缺少目标邮箱地址/
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const {
|
||||||
|
buildCloudflareTempEmailHeaders,
|
||||||
|
getCloudflareTempEmailAddressFromResponse,
|
||||||
|
normalizeCloudflareTempEmailBaseUrl,
|
||||||
|
normalizeCloudflareTempEmailDomain,
|
||||||
|
normalizeCloudflareTempEmailDomains,
|
||||||
|
normalizeCloudflareTempEmailMailApiMessages,
|
||||||
|
} = require('../cloudflare-temp-email-utils.js');
|
||||||
|
|
||||||
|
test('normalizeCloudflareTempEmailBaseUrl normalizes host and preserves path', () => {
|
||||||
|
assert.equal(
|
||||||
|
normalizeCloudflareTempEmailBaseUrl('temp.example.com/api/'),
|
||||||
|
'https://temp.example.com/api'
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
normalizeCloudflareTempEmailBaseUrl('http://127.0.0.1:8787'),
|
||||||
|
'http://127.0.0.1:8787'
|
||||||
|
);
|
||||||
|
assert.equal(normalizeCloudflareTempEmailBaseUrl('::::'), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeCloudflareTempEmailDomain and domains de-duplicate valid entries', () => {
|
||||||
|
assert.equal(normalizeCloudflareTempEmailDomain('@Mail.Example.com'), 'mail.example.com');
|
||||||
|
assert.equal(normalizeCloudflareTempEmailDomain('not-a-domain'), '');
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeCloudflareTempEmailDomains(['mail.example.com', 'MAIL.EXAMPLE.COM', 'bad-value']),
|
||||||
|
['mail.example.com']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildCloudflareTempEmailHeaders includes auth headers and content type when needed', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
buildCloudflareTempEmailHeaders(
|
||||||
|
{
|
||||||
|
adminAuth: 'admin-secret',
|
||||||
|
customAuth: 'site-secret',
|
||||||
|
},
|
||||||
|
{ json: true }
|
||||||
|
),
|
||||||
|
{
|
||||||
|
'x-admin-auth': 'admin-secret',
|
||||||
|
'x-custom-auth': 'site-secret',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeCloudflareTempEmailMailApiMessages extracts sender, subject, code, and address from raw mime', () => {
|
||||||
|
const messages = normalizeCloudflareTempEmailMailApiMessages({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'mail-1',
|
||||||
|
address: 'user@example.com',
|
||||||
|
created_at: '2026-04-13T09:15:00.000Z',
|
||||||
|
raw: [
|
||||||
|
'From: OpenAI <noreply@tm.openai.com>',
|
||||||
|
'Subject: =?UTF-8?B?T3BlbkFJIHZlcmlmaWNhdGlvbiBjb2Rl?=',
|
||||||
|
'Content-Type: text/plain; charset=UTF-8',
|
||||||
|
'',
|
||||||
|
'Your verification code is 654321.',
|
||||||
|
].join('\r\n'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(messages.length, 1);
|
||||||
|
assert.equal(messages[0].id, 'mail-1');
|
||||||
|
assert.equal(messages[0].address, 'user@example.com');
|
||||||
|
assert.equal(messages[0].subject, 'OpenAI verification code');
|
||||||
|
assert.equal(messages[0].from.emailAddress.address, 'OpenAI <noreply@tm.openai.com>');
|
||||||
|
assert.match(messages[0].bodyPreview, /654321/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeCloudflareTempEmailMailApiMessages decodes multipart quoted printable html bodies', () => {
|
||||||
|
const messages = normalizeCloudflareTempEmailMailApiMessages([
|
||||||
|
{
|
||||||
|
id: 'mail-2',
|
||||||
|
address: 'user@example.com',
|
||||||
|
received_at: '2026-04-13T09:20:00.000Z',
|
||||||
|
source: [
|
||||||
|
'From: ChatGPT <noreply@tm.openai.com>',
|
||||||
|
'Subject: Login code',
|
||||||
|
'Content-Type: multipart/alternative; boundary="abc123"',
|
||||||
|
'',
|
||||||
|
'--abc123',
|
||||||
|
'Content-Type: text/html; charset=UTF-8',
|
||||||
|
'Content-Transfer-Encoding: quoted-printable',
|
||||||
|
'',
|
||||||
|
'<p>Your login code is <strong>112233</strong>.</p>',
|
||||||
|
'--abc123--',
|
||||||
|
].join('\r\n'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(messages.length, 1);
|
||||||
|
assert.match(messages[0].bodyPreview, /112233/);
|
||||||
|
assert.equal(messages[0].subject, 'Login code');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getCloudflareTempEmailAddressFromResponse supports direct and nested response shapes', () => {
|
||||||
|
assert.equal(getCloudflareTempEmailAddressFromResponse({ address: 'one@example.com' }), 'one@example.com');
|
||||||
|
assert.equal(getCloudflareTempEmailAddressFromResponse({ data: { address: 'two@example.com' } }), 'two@example.com');
|
||||||
|
});
|
||||||
@@ -26,6 +26,20 @@ test('Hotmail API对接应接入微软邮箱 helper 而不是旧远程服务占
|
|||||||
const background = fs.readFileSync('background.js', 'utf8');
|
const background = fs.readFileSync('background.js', 'utf8');
|
||||||
|
|
||||||
assert.match(background, /importScripts\([^)]*microsoft-email\.js[^)]*\)/, 'background 应加载微软邮箱 helper');
|
assert.match(background, /importScripts\([^)]*microsoft-email\.js[^)]*\)/, 'background 应加载微软邮箱 helper');
|
||||||
assert.match(background, /fetchMicrosoftVerificationCode/, '步骤 4\/7 应接入微软验证码读取 helper');
|
|
||||||
assert.match(background, /fetchMicrosoftMailboxMessages/, '账号校验和最新验证码应接入微软邮箱列表 helper');
|
assert.match(background, /fetchMicrosoftMailboxMessages/, '账号校验和最新验证码应接入微软邮箱列表 helper');
|
||||||
|
assert.match(
|
||||||
|
background,
|
||||||
|
/fetchMicrosoftMailboxMessages\(\{[\s\S]*mailbox,[\s\S]*top:\s*10,/,
|
||||||
|
'微软邮箱 helper 应按请求的邮箱夹读取消息'
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
background,
|
||||||
|
/for \(const mailbox of mailboxes\) \{\s*const result = await requestHotmailRemoteMailbox\(workingAccount, mailbox\);/s,
|
||||||
|
'API对接模式不应丢掉 INBOX\/Junk 的逐邮箱夹轮询'
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
background,
|
||||||
|
/pickVerificationMessageWithTimeFallback\(fetchResult\.messages, \{/,
|
||||||
|
'步骤 4\/7 应继续复用现有验证码筛选与时间回退逻辑'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const {
|
||||||
|
findIcloudAliasArray,
|
||||||
|
findIcloudAliasByEmail,
|
||||||
|
getConfiguredIcloudHostPreference,
|
||||||
|
getIcloudHostHintFromMessage,
|
||||||
|
getIcloudLoginUrlForHost,
|
||||||
|
getIcloudSetupUrlForHost,
|
||||||
|
normalizeBooleanMap,
|
||||||
|
normalizeIcloudAliasList,
|
||||||
|
normalizeIcloudAliasRecord,
|
||||||
|
normalizeIcloudHost,
|
||||||
|
pickReusableIcloudAlias,
|
||||||
|
toNormalizedEmailSet,
|
||||||
|
} = require('../icloud-utils.js');
|
||||||
|
|
||||||
|
test('normalizeIcloudHost and host preference helpers resolve supported hosts', () => {
|
||||||
|
assert.equal(normalizeIcloudHost('www.icloud.com'), 'icloud.com');
|
||||||
|
assert.equal(normalizeIcloudHost('setup.icloud.com.cn'), 'icloud.com.cn');
|
||||||
|
assert.equal(normalizeIcloudHost('example.com'), '');
|
||||||
|
|
||||||
|
assert.equal(getConfiguredIcloudHostPreference({ icloudHostPreference: 'icloud.com' }), 'icloud.com');
|
||||||
|
assert.equal(getConfiguredIcloudHostPreference({ icloudHostPreference: 'auto' }), '');
|
||||||
|
assert.equal(getIcloudLoginUrlForHost('icloud.com.cn'), 'https://www.icloud.com.cn/');
|
||||||
|
assert.equal(getIcloudSetupUrlForHost('icloud.com'), 'https://setup.icloud.com/setup/ws/1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getIcloudHostHintFromMessage can infer host from error text', () => {
|
||||||
|
assert.equal(getIcloudHostHintFromMessage('status 401 from setup.icloud.com.cn/setup/ws/1'), 'icloud.com.cn');
|
||||||
|
assert.equal(getIcloudHostHintFromMessage('request failed at https://www.icloud.com/'), 'icloud.com');
|
||||||
|
assert.equal(getIcloudHostHintFromMessage('unknown host'), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('findIcloudAliasArray finds nested alias collections', () => {
|
||||||
|
const payload = {
|
||||||
|
result: {
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{ hme: 'first@icloud.com', anonymousId: 'a1' },
|
||||||
|
{ hme: 'second@icloud.com', anonymousId: 'a2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.deepEqual(findIcloudAliasArray(payload), payload.result.data.items);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeIcloudAliasRecord merges used and preserved state', () => {
|
||||||
|
const alias = normalizeIcloudAliasRecord({
|
||||||
|
anonymousId: 'alias-1',
|
||||||
|
hme: 'Demo@iCloud.com',
|
||||||
|
label: 'Test',
|
||||||
|
note: 'Created by test',
|
||||||
|
state: 'active',
|
||||||
|
}, {
|
||||||
|
usedEmails: ['demo@icloud.com'],
|
||||||
|
preservedEmails: ['demo@icloud.com'],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(alias, {
|
||||||
|
anonymousId: 'alias-1',
|
||||||
|
email: 'demo@icloud.com',
|
||||||
|
label: 'Test',
|
||||||
|
note: 'Created by test',
|
||||||
|
state: 'active',
|
||||||
|
active: true,
|
||||||
|
used: true,
|
||||||
|
preserved: true,
|
||||||
|
createdAt: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeIcloudAliasList orders active unused aliases before used aliases', () => {
|
||||||
|
const aliases = normalizeIcloudAliasList({
|
||||||
|
hmeEmails: [
|
||||||
|
{ hme: 'used@icloud.com', anonymousId: 'u1', active: true },
|
||||||
|
{ hme: 'fresh@icloud.com', anonymousId: 'f1', active: true },
|
||||||
|
{ hme: 'inactive@icloud.com', anonymousId: 'i1', active: false },
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
usedEmails: ['used@icloud.com'],
|
||||||
|
preservedEmails: ['inactive@icloud.com'],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(aliases.map((alias) => alias.email), [
|
||||||
|
'fresh@icloud.com',
|
||||||
|
'used@icloud.com',
|
||||||
|
'inactive@icloud.com',
|
||||||
|
]);
|
||||||
|
assert.equal(aliases[2].preserved, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickReusableIcloudAlias and findIcloudAliasByEmail select expected aliases', () => {
|
||||||
|
const aliases = normalizeIcloudAliasList({
|
||||||
|
hmeEmails: [
|
||||||
|
{ hme: 'used@icloud.com', anonymousId: 'u1', active: true },
|
||||||
|
{ hme: 'fresh@icloud.com', anonymousId: 'f1', active: true },
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
usedEmails: ['used@icloud.com'],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(pickReusableIcloudAlias(aliases)?.email, 'fresh@icloud.com');
|
||||||
|
assert.equal(findIcloudAliasByEmail(aliases, 'FRESH@ICLOUD.COM')?.anonymousId, 'f1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeBooleanMap and toNormalizedEmailSet normalize keys and truthy entries', () => {
|
||||||
|
const normalized = normalizeBooleanMap({
|
||||||
|
' Demo@icloud.com ': 1,
|
||||||
|
'skip@icloud.com': 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(normalized, {
|
||||||
|
'demo@icloud.com': true,
|
||||||
|
'skip@icloud.com': false,
|
||||||
|
});
|
||||||
|
assert.deepEqual([...toNormalizedEmailSet(normalized)], ['demo@icloud.com']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const {
|
||||||
|
DEFAULT_LUCKMAIL_BASE_URL,
|
||||||
|
DEFAULT_LUCKMAIL_EMAIL_TYPE,
|
||||||
|
DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME,
|
||||||
|
buildLuckmailBaselineCursor,
|
||||||
|
buildLuckmailMailCursor,
|
||||||
|
extractLuckmailVerificationCode,
|
||||||
|
filterReusableLuckmailPurchases,
|
||||||
|
isLuckmailMailNewerThanCursor,
|
||||||
|
isLuckmailPurchaseForProject,
|
||||||
|
normalizeLuckmailBaseUrl,
|
||||||
|
normalizeLuckmailEmailType,
|
||||||
|
normalizeLuckmailProjectName,
|
||||||
|
normalizeLuckmailPurchaseListPage,
|
||||||
|
normalizeLuckmailTags,
|
||||||
|
normalizeLuckmailUsedPurchases,
|
||||||
|
normalizeTimestamp,
|
||||||
|
normalizeLuckmailTokenCode,
|
||||||
|
normalizeLuckmailTokenMail,
|
||||||
|
pickReusableLuckmailPurchase,
|
||||||
|
pickLuckmailVerificationMail,
|
||||||
|
} = require('../luckmail-utils.js');
|
||||||
|
|
||||||
|
test('normalizeLuckmailEmailType keeps supported values and falls back to ms_graph', () => {
|
||||||
|
assert.equal(normalizeLuckmailEmailType('self_built'), 'self_built');
|
||||||
|
assert.equal(normalizeLuckmailEmailType('ms_imap'), 'ms_imap');
|
||||||
|
assert.equal(normalizeLuckmailEmailType('ms_graph'), 'ms_graph');
|
||||||
|
assert.equal(normalizeLuckmailEmailType('google_variant'), 'google_variant');
|
||||||
|
assert.equal(normalizeLuckmailEmailType(''), DEFAULT_LUCKMAIL_EMAIL_TYPE);
|
||||||
|
assert.equal(normalizeLuckmailEmailType('unknown'), DEFAULT_LUCKMAIL_EMAIL_TYPE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeLuckmailBaseUrl trims invalid input to default base url', () => {
|
||||||
|
assert.equal(normalizeLuckmailBaseUrl(''), DEFAULT_LUCKMAIL_BASE_URL);
|
||||||
|
assert.equal(normalizeLuckmailBaseUrl('https://mails.luckyous.com/'), DEFAULT_LUCKMAIL_BASE_URL);
|
||||||
|
assert.equal(normalizeLuckmailBaseUrl('https://demo.example.com/api/'), 'https://demo.example.com/api');
|
||||||
|
assert.equal(normalizeLuckmailBaseUrl('notaurl'), DEFAULT_LUCKMAIL_BASE_URL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeLuckmailTokenCode and normalizeLuckmailTokenMail extract verification code', () => {
|
||||||
|
const tokenCode = normalizeLuckmailTokenCode({
|
||||||
|
email_address: 'demo@outlook.com',
|
||||||
|
project: 'openai',
|
||||||
|
has_new_mail: true,
|
||||||
|
verification_code: '123456',
|
||||||
|
mail: {
|
||||||
|
message_id: 'mail-1',
|
||||||
|
from: 'noreply@openai.com',
|
||||||
|
subject: 'Your ChatGPT code is 123456',
|
||||||
|
received_at: '2026-04-14T10:00:00Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(tokenCode.verification_code, '123456');
|
||||||
|
assert.equal(tokenCode.mail.message_id, 'mail-1');
|
||||||
|
assert.equal(tokenCode.mail.verification_code, '123456');
|
||||||
|
|
||||||
|
const normalizedMail = normalizeLuckmailTokenMail({
|
||||||
|
message_id: 'mail-2',
|
||||||
|
from: 'noreply@openai.com',
|
||||||
|
subject: 'OpenAI security message',
|
||||||
|
body: 'Your verification code is 654321.',
|
||||||
|
received_at: '2026-04-14T10:01:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(normalizedMail.verification_code, '654321');
|
||||||
|
assert.equal(extractLuckmailVerificationCode('你的验证码为 778899'), '778899');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeLuckmailProjectName and isLuckmailPurchaseForProject match openai case-insensitively', () => {
|
||||||
|
assert.equal(normalizeLuckmailProjectName(' OpenAi '), 'openai');
|
||||||
|
assert.equal(isLuckmailPurchaseForProject({
|
||||||
|
id: 1,
|
||||||
|
email_address: 'demo@outlook.com',
|
||||||
|
token: 'tok-1',
|
||||||
|
project_name: 'OpenAi',
|
||||||
|
}, 'openai'), true);
|
||||||
|
assert.equal(isLuckmailPurchaseForProject({
|
||||||
|
id: 2,
|
||||||
|
email_address: 'other@example.com',
|
||||||
|
token: 'tok-2',
|
||||||
|
project: 'OtherProject',
|
||||||
|
}, 'openai'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeLuckmailPurchaseListPage and normalizeLuckmailTags normalize list payloads', () => {
|
||||||
|
const page = normalizeLuckmailPurchaseListPage({
|
||||||
|
list: [{
|
||||||
|
id: 3,
|
||||||
|
email_address: 'demo@outlook.com',
|
||||||
|
token: 'tok-3',
|
||||||
|
project_name: 'OpenAi',
|
||||||
|
}],
|
||||||
|
total: 4,
|
||||||
|
page: 2,
|
||||||
|
page_size: 1,
|
||||||
|
});
|
||||||
|
assert.equal(page.total, 4);
|
||||||
|
assert.equal(page.page, 2);
|
||||||
|
assert.equal(page.page_size, 1);
|
||||||
|
assert.equal(page.list[0].project_code, 'openai');
|
||||||
|
|
||||||
|
const tags = normalizeLuckmailTags([{
|
||||||
|
id: 9,
|
||||||
|
name: DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME,
|
||||||
|
limit_type: 0,
|
||||||
|
}]);
|
||||||
|
assert.deepEqual(tags[0], {
|
||||||
|
id: 9,
|
||||||
|
name: DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME,
|
||||||
|
remark: '',
|
||||||
|
limit_type: 0,
|
||||||
|
purchase_count: 0,
|
||||||
|
created_at: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeLuckmailUsedPurchases keeps positive numeric keys only', () => {
|
||||||
|
assert.deepEqual(normalizeLuckmailUsedPurchases({
|
||||||
|
1: true,
|
||||||
|
foo: true,
|
||||||
|
'-2': true,
|
||||||
|
3: false,
|
||||||
|
}), {
|
||||||
|
1: true,
|
||||||
|
3: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickReusableLuckmailPurchase only returns reusable openai purchase', () => {
|
||||||
|
const purchases = [{
|
||||||
|
id: 10,
|
||||||
|
email_address: 'used@outlook.com',
|
||||||
|
token: 'tok-used',
|
||||||
|
project_name: 'openai',
|
||||||
|
}, {
|
||||||
|
id: 11,
|
||||||
|
email_address: 'preserved@outlook.com',
|
||||||
|
token: 'tok-preserved',
|
||||||
|
project_name: 'OpenAi',
|
||||||
|
tag_name: DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME,
|
||||||
|
}, {
|
||||||
|
id: 12,
|
||||||
|
email_address: 'disabled@outlook.com',
|
||||||
|
token: 'tok-disabled',
|
||||||
|
project_name: 'openai',
|
||||||
|
user_disabled: 1,
|
||||||
|
}, {
|
||||||
|
id: 13,
|
||||||
|
email_address: 'expired@outlook.com',
|
||||||
|
token: 'tok-expired',
|
||||||
|
project_name: 'openai',
|
||||||
|
warranty_until: '2026-04-14T09:00:00Z',
|
||||||
|
}, {
|
||||||
|
id: 14,
|
||||||
|
email_address: 'other@example.com',
|
||||||
|
token: 'tok-other',
|
||||||
|
project_name: 'other',
|
||||||
|
}, {
|
||||||
|
id: 15,
|
||||||
|
email_address: 'ready@outlook.com',
|
||||||
|
token: 'tok-ready',
|
||||||
|
project_name: 'OpenAi',
|
||||||
|
warranty_until: '2026-04-15T09:00:00Z',
|
||||||
|
}];
|
||||||
|
|
||||||
|
const reusable = filterReusableLuckmailPurchases(purchases, {
|
||||||
|
projectCode: 'openai',
|
||||||
|
usedPurchases: { 10: true },
|
||||||
|
preserveTagName: DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME,
|
||||||
|
now: Date.parse('2026-04-14T10:00:00Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(reusable.map((purchase) => purchase.id), [15]);
|
||||||
|
assert.equal(pickReusableLuckmailPurchase(purchases, {
|
||||||
|
projectCode: 'openai',
|
||||||
|
usedPurchases: { 10: true },
|
||||||
|
preserveTagName: DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME,
|
||||||
|
now: Date.parse('2026-04-14T10:00:00Z'),
|
||||||
|
}).id, 15);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickLuckmailVerificationMail respects sender filters, time filters, and excluded codes', () => {
|
||||||
|
const match = pickLuckmailVerificationMail([
|
||||||
|
{
|
||||||
|
message_id: 'old-mail',
|
||||||
|
from: 'noreply@openai.com',
|
||||||
|
subject: 'Your code is 111111',
|
||||||
|
received_at: '2026-04-14T09:59:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message_id: 'new-mail',
|
||||||
|
from: 'noreply@openai.com',
|
||||||
|
subject: 'Your code is 222222',
|
||||||
|
received_at: '2026-04-14T10:05:00Z',
|
||||||
|
},
|
||||||
|
], {
|
||||||
|
senderFilters: ['openai'],
|
||||||
|
subjectFilters: ['code'],
|
||||||
|
excludeCodes: ['111111'],
|
||||||
|
afterTimestamp: Date.parse('2026-04-14T10:00:00Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(match.code, '222222');
|
||||||
|
assert.equal(match.mail.message_id, 'new-mail');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isLuckmailMailNewerThanCursor compares message id and timestamp safely', () => {
|
||||||
|
const cursor = buildLuckmailMailCursor({
|
||||||
|
message_id: 'mail-1',
|
||||||
|
received_at: '2026-04-14T10:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(isLuckmailMailNewerThanCursor({
|
||||||
|
message_id: 'mail-1',
|
||||||
|
received_at: '2026-04-14T10:00:00Z',
|
||||||
|
}, cursor), false);
|
||||||
|
|
||||||
|
assert.equal(isLuckmailMailNewerThanCursor({
|
||||||
|
message_id: 'mail-2',
|
||||||
|
received_at: '2026-04-14T10:01:00Z',
|
||||||
|
}, cursor), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeLuckmailMailCursor tolerates null cursor input', () => {
|
||||||
|
const { normalizeLuckmailMailCursor } = require('../luckmail-utils.js');
|
||||||
|
assert.deepEqual(normalizeLuckmailMailCursor(null), {
|
||||||
|
messageId: '',
|
||||||
|
receivedAt: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeTimestamp treats LuckMail naive datetime strings as UTC', () => {
|
||||||
|
assert.equal(
|
||||||
|
normalizeTimestamp('2026-04-14 13:32:05'),
|
||||||
|
Date.UTC(2026, 3, 14, 13, 32, 5, 0)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildLuckmailBaselineCursor tracks newest existing mail as baseline', () => {
|
||||||
|
const cursor = buildLuckmailBaselineCursor([
|
||||||
|
{
|
||||||
|
message_id: 'mail-old',
|
||||||
|
received_at: '2026-04-14 13:31:15',
|
||||||
|
subject: '你的 ChatGPT 代码为 111111',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message_id: 'mail-new',
|
||||||
|
received_at: '2026-04-14 13:32:05',
|
||||||
|
subject: '你的 ChatGPT 代码为 222222',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.deepEqual(cursor, {
|
||||||
|
messageId: 'mail-new',
|
||||||
|
receivedAt: '2026-04-14 13:32:05',
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,9 +5,10 @@ const {
|
|||||||
extractVerificationCodeFromMessages,
|
extractVerificationCodeFromMessages,
|
||||||
fetchMicrosoftMailboxMessages,
|
fetchMicrosoftMailboxMessages,
|
||||||
fetchMicrosoftVerificationCode,
|
fetchMicrosoftVerificationCode,
|
||||||
|
normalizeMailboxId,
|
||||||
} = require('../microsoft-email.js');
|
} = require('../microsoft-email.js');
|
||||||
|
|
||||||
test('extractVerificationCodeFromMessages 只命中过滤时间后的 OpenAI 微软邮件', () => {
|
test('extractVerificationCodeFromMessages 支持显式过滤条件并跳过排除的验证码', () => {
|
||||||
const result = extractVerificationCodeFromMessages([
|
const result = extractVerificationCodeFromMessages([
|
||||||
{
|
{
|
||||||
From: { EmailAddress: { Address: 'noreply@openai.com' } },
|
From: { EmailAddress: { Address: 'noreply@openai.com' } },
|
||||||
@@ -32,6 +33,9 @@ test('extractVerificationCodeFromMessages 只命中过滤时间后的 OpenAI 微
|
|||||||
},
|
},
|
||||||
], {
|
], {
|
||||||
filterAfterTimestamp: Date.UTC(2026, 3, 14, 9, 30, 0),
|
filterAfterTimestamp: Date.UTC(2026, 3, 14, 9, 30, 0),
|
||||||
|
senderFilters: ['openai'],
|
||||||
|
subjectFilters: ['verification'],
|
||||||
|
excludeCodes: ['112233'],
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(result, {
|
assert.deepEqual(result, {
|
||||||
@@ -40,14 +44,46 @@ test('extractVerificationCodeFromMessages 只命中过滤时间后的 OpenAI 微
|
|||||||
messageId: 'matched',
|
messageId: 'matched',
|
||||||
sender: 'account-security@openai.com',
|
sender: 'account-security@openai.com',
|
||||||
subject: 'OpenAI verification',
|
subject: 'OpenAI verification',
|
||||||
|
mailbox: 'INBOX',
|
||||||
|
message: {
|
||||||
|
mailbox: 'INBOX',
|
||||||
|
from: {
|
||||||
|
emailAddress: {
|
||||||
|
address: 'account-security@openai.com',
|
||||||
|
name: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
subject: 'OpenAI verification',
|
||||||
|
receivedDateTime: '2026-04-14T10:05:00.000Z',
|
||||||
|
bodyPreview: 'Use 334455 to continue',
|
||||||
|
body: {
|
||||||
|
content: '',
|
||||||
|
},
|
||||||
|
id: 'matched',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fetchMicrosoftMailboxMessages 使用 refresh token 拉取微软邮箱列表并返回新 token', async () => {
|
test('normalizeMailboxId 将 Junk 归一为微软邮箱夹 ID', () => {
|
||||||
|
assert.equal(normalizeMailboxId('INBOX'), 'inbox');
|
||||||
|
assert.equal(normalizeMailboxId('junk'), 'junkemail');
|
||||||
|
assert.equal(normalizeMailboxId('Junk Email'), 'junkemail');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fetchMicrosoftMailboxMessages 会回退到可用的 token 策略并保留邮箱夹信息', async () => {
|
||||||
const requests = [];
|
const requests = [];
|
||||||
const fetchImpl = async (url, options = {}) => {
|
const fetchImpl = async (url, options = {}) => {
|
||||||
requests.push({ url, options });
|
requests.push({ url, options });
|
||||||
if (String(url).includes('/oauth2/v2.0/token')) {
|
if (String(url).includes('/oauth2/v2.0/token')) {
|
||||||
|
const params = new URLSearchParams(String(options.body || ''));
|
||||||
|
if (String(url).includes('/common/') && params.get('scope')?.includes('Mail.Read')) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
statusText: 'Bad Request',
|
||||||
|
text: async () => JSON.stringify({ error_description: 'common delegated failed' }),
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@@ -57,16 +93,17 @@ test('fetchMicrosoftMailboxMessages 使用 refresh token 拉取微软邮箱列
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert.match(String(url), /graph\.microsoft\.com\/v1\.0\/me\/mailFolders\/junkemail\/messages/);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
value: [
|
value: [
|
||||||
{
|
{
|
||||||
From: { EmailAddress: { Address: 'noreply@openai.com' } },
|
from: { emailAddress: { address: 'noreply@openai.com' } },
|
||||||
Subject: 'OpenAI verification',
|
subject: 'OpenAI verification',
|
||||||
BodyPreview: 'Use 445566 to continue',
|
bodyPreview: 'Use 445566 to continue',
|
||||||
ReceivedDateTime: '2026-04-14T10:06:00.000Z',
|
receivedDateTime: '2026-04-14T10:06:00.000Z',
|
||||||
Id: 'mail-1',
|
id: 'mail-1',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@@ -76,18 +113,25 @@ test('fetchMicrosoftMailboxMessages 使用 refresh token 拉取微软邮箱列
|
|||||||
const result = await fetchMicrosoftMailboxMessages({
|
const result = await fetchMicrosoftMailboxMessages({
|
||||||
clientId: 'client-1',
|
clientId: 'client-1',
|
||||||
refreshToken: 'refresh-token-1',
|
refreshToken: 'refresh-token-1',
|
||||||
|
mailbox: 'Junk',
|
||||||
top: 5,
|
top: 5,
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(requests.length, 2);
|
assert.equal(requests.length, 3);
|
||||||
assert.equal(result.nextRefreshToken, 'refresh-token-next');
|
assert.equal(result.nextRefreshToken, 'refresh-token-next');
|
||||||
|
assert.equal(result.tokenStrategy, 'entra-consumers-delegated');
|
||||||
|
assert.equal(result.transport, 'graph');
|
||||||
assert.equal(result.messages.length, 1);
|
assert.equal(result.messages.length, 1);
|
||||||
assert.equal(result.messages[0].id, 'mail-1');
|
assert.equal(result.messages[0].id, 'mail-1');
|
||||||
|
assert.equal(result.messages[0].mailbox, 'Junk');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fetchMicrosoftVerificationCode 会重试直到命中最新验证码', async () => {
|
test('fetchMicrosoftVerificationCode 会按邮箱夹轮询并在 Junk 中命中最新验证码', async () => {
|
||||||
let messageRequestCount = 0;
|
const mailboxRequests = {
|
||||||
|
inbox: 0,
|
||||||
|
junkemail: 0,
|
||||||
|
};
|
||||||
const logs = [];
|
const logs = [];
|
||||||
const fetchImpl = async (url) => {
|
const fetchImpl = async (url) => {
|
||||||
if (String(url).includes('/oauth2/v2.0/token')) {
|
if (String(url).includes('/oauth2/v2.0/token')) {
|
||||||
@@ -100,8 +144,9 @@ test('fetchMicrosoftVerificationCode 会重试直到命中最新验证码', asyn
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
messageRequestCount += 1;
|
const urlString = String(url);
|
||||||
if (messageRequestCount === 1) {
|
if (urlString.includes('/mailFolders/inbox/messages')) {
|
||||||
|
mailboxRequests.inbox += 1;
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@@ -116,11 +161,28 @@ test('fetchMicrosoftVerificationCode 会重试直到命中最新验证码', asyn
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert.match(urlString, /mailFolders\/junkemail\/messages/);
|
||||||
|
mailboxRequests.junkemail += 1;
|
||||||
|
if (mailboxRequests.junkemail === 1) {
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
value: [{
|
value: [{
|
||||||
From: { EmailAddress: { Address: 'noreply@openai.com' } },
|
from: { emailAddress: { address: 'no-reply@example.com' } },
|
||||||
|
subject: 'Nothing useful',
|
||||||
|
bodyPreview: 'Still no code',
|
||||||
|
receivedDateTime: '2026-04-14T10:05:00.000Z',
|
||||||
|
id: 'mail-ignore-2',
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
value: [{
|
||||||
|
from: { emailAddress: { address: 'account-security@openai.com' } },
|
||||||
Subject: 'Your verification code',
|
Subject: 'Your verification code',
|
||||||
BodyPreview: '667788',
|
BodyPreview: '667788',
|
||||||
ReceivedDateTime: '2026-04-14T10:10:00.000Z',
|
ReceivedDateTime: '2026-04-14T10:10:00.000Z',
|
||||||
@@ -135,13 +197,19 @@ test('fetchMicrosoftVerificationCode 会重试直到命中最新验证码', asyn
|
|||||||
clientId: 'client-2',
|
clientId: 'client-2',
|
||||||
maxRetries: 2,
|
maxRetries: 2,
|
||||||
retryDelayMs: 0,
|
retryDelayMs: 0,
|
||||||
|
mailboxes: ['INBOX', 'Junk'],
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
log: (message) => logs.push(message),
|
log: (message) => logs.push(message),
|
||||||
filterAfterTimestamp: Date.UTC(2026, 3, 14, 9, 0, 0),
|
filterAfterTimestamp: Date.UTC(2026, 3, 14, 9, 0, 0),
|
||||||
|
senderFilters: ['openai'],
|
||||||
|
subjectFilters: ['verification'],
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(result.code, '667788');
|
assert.equal(result.code, '667788');
|
||||||
assert.equal(result.messageId, 'mail-hit');
|
assert.equal(result.messageId, 'mail-hit');
|
||||||
assert.equal(result.nextRefreshToken, 'refresh-token-next-2');
|
assert.equal(result.nextRefreshToken, 'refresh-token-next-2');
|
||||||
|
assert.equal(result.mailbox, 'Junk');
|
||||||
|
assert.equal(mailboxRequests.inbox, 2);
|
||||||
|
assert.equal(mailboxRequests.junkemail, 2);
|
||||||
assert.equal(logs.some((message) => /retrying/i.test(message)), true);
|
assert.equal(logs.some((message) => /retrying/i.test(message)), true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,7 +52,14 @@ function extractFunction(name) {
|
|||||||
|
|
||||||
const bundle = [
|
const bundle = [
|
||||||
extractFunction('getTabRegistry'),
|
extractFunction('getTabRegistry'),
|
||||||
|
extractFunction('normalizeEmailGenerator'),
|
||||||
|
extractFunction('normalizeMail2925Mode'),
|
||||||
|
extractFunction('getMail2925Mode'),
|
||||||
extractFunction('parseUrlSafely'),
|
extractFunction('parseUrlSafely'),
|
||||||
|
extractFunction('isHotmailProvider'),
|
||||||
|
extractFunction('isCustomMailProvider'),
|
||||||
|
extractFunction('isGeneratedAliasProvider'),
|
||||||
|
extractFunction('shouldUseCustomRegistrationEmail'),
|
||||||
extractFunction('isLocalhostOAuthCallbackUrl'),
|
extractFunction('isLocalhostOAuthCallbackUrl'),
|
||||||
extractFunction('isLocalhostOAuthCallbackTabMatch'),
|
extractFunction('isLocalhostOAuthCallbackTabMatch'),
|
||||||
extractFunction('closeLocalhostCallbackTabs'),
|
extractFunction('closeLocalhostCallbackTabs'),
|
||||||
@@ -62,6 +69,12 @@ const bundle = [
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
const api = new Function(`
|
const api = new Function(`
|
||||||
|
const HOTMAIL_PROVIDER = 'hotmail-api';
|
||||||
|
const CLOUDFLARE_TEMP_EMAIL_PROVIDER = 'cloudflare-temp-email';
|
||||||
|
const CLOUDFLARE_TEMP_EMAIL_GENERATOR = 'cloudflare-temp-email';
|
||||||
|
const MAIL_2925_MODE_PROVIDE = 'provide';
|
||||||
|
const MAIL_2925_MODE_RECEIVE = 'receive';
|
||||||
|
const DEFAULT_MAIL_2925_MODE = MAIL_2925_MODE_PROVIDE;
|
||||||
let currentState = {
|
let currentState = {
|
||||||
tabRegistry: {
|
tabRegistry: {
|
||||||
'signup-page': { tabId: 1, ready: true },
|
'signup-page': { tabId: 1, ready: true },
|
||||||
@@ -96,12 +109,37 @@ async function setEmailState(email) {
|
|||||||
currentState = { ...currentState, email };
|
currentState = { ...currentState, email };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function setEmailStateSilently(email) {
|
||||||
|
currentState = { ...currentState, email };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHotmailProvider() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLuckmailProvider() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function patchHotmailAccount() {}
|
||||||
|
|
||||||
|
async function clearLuckmailRuntimeState() {}
|
||||||
|
|
||||||
|
function shouldUseCustomRegistrationEmail() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function broadcastDataUpdate() {}
|
function broadcastDataUpdate() {}
|
||||||
|
|
||||||
async function addLog(message) {
|
async function addLog(message) {
|
||||||
logMessages.push(message);
|
logMessages.push(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function finalizeIcloudAliasAfterSuccessfulFlow() {}
|
||||||
|
function shouldUseCustomRegistrationEmail() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
${bundle}
|
${bundle}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -57,10 +57,12 @@ async function testPollFreshVerificationCodeRethrowsStop() {
|
|||||||
extractFunction('pollFreshVerificationCode'),
|
extractFunction('pollFreshVerificationCode'),
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
const api = new Function(`
|
const api = new Function(`
|
||||||
let stopRequested = false;
|
let stopRequested = false;
|
||||||
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
|
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
|
||||||
const HOTMAIL_PROVIDER = 'hotmail-api';
|
const HOTMAIL_PROVIDER = 'hotmail-api';
|
||||||
|
const LUCKMAIL_PROVIDER = 'luckmail-api';
|
||||||
|
const CLOUDFLARE_TEMP_EMAIL_PROVIDER = 'cloudflare-temp-email';
|
||||||
const VERIFICATION_POLL_MAX_ROUNDS = 5;
|
const VERIFICATION_POLL_MAX_ROUNDS = 5;
|
||||||
const logs = [];
|
const logs = [];
|
||||||
let resendCalls = 0;
|
let resendCalls = 0;
|
||||||
@@ -71,6 +73,9 @@ function getHotmailVerificationPollConfig() {
|
|||||||
async function pollHotmailVerificationCode() {
|
async function pollHotmailVerificationCode() {
|
||||||
throw new Error('hotmail path should not run in this test');
|
throw new Error('hotmail path should not run in this test');
|
||||||
}
|
}
|
||||||
|
async function pollLuckmailVerificationCode() {
|
||||||
|
throw new Error('luckmail path should not run in this test');
|
||||||
|
}
|
||||||
function getVerificationCodeStateKey(step) {
|
function getVerificationCodeStateKey(step) {
|
||||||
return step === 4 ? 'lastSignupCode' : 'lastLoginCode';
|
return step === 4 ? 'lastSignupCode' : 'lastLoginCode';
|
||||||
}
|
}
|
||||||
@@ -119,9 +124,11 @@ async function testResolveVerificationStepRethrowsStopFromFreshRequest() {
|
|||||||
extractFunction('resolveVerificationStep'),
|
extractFunction('resolveVerificationStep'),
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
const api = new Function(`
|
const api = new Function(`
|
||||||
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
|
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
|
||||||
const HOTMAIL_PROVIDER = 'hotmail-api';
|
const HOTMAIL_PROVIDER = 'hotmail-api';
|
||||||
|
const LUCKMAIL_PROVIDER = 'luckmail-api';
|
||||||
|
const CLOUDFLARE_TEMP_EMAIL_PROVIDER = 'cloudflare-temp-email';
|
||||||
const logs = [];
|
const logs = [];
|
||||||
let pollCalls = 0;
|
let pollCalls = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
# 开发者 AI 开发与 PR 流程
|
||||||
|
|
||||||
|
请开发者们先让自己的 AI 阅读此文件。
|
||||||
|
|
||||||
|
AI 在本仓库里进行开发、整理改动、发起 PR、更新 PR、补充说明时,都必须按本文执行,不能跳步,不能猜,不能自创流程。
|
||||||
|
|
||||||
|
本文面向“开发者自己电脑上的 AI”,不是给仓库维护者批量处理别人 PR 用的,也不是版本发布流程。
|
||||||
|
|
||||||
|
## 使用前准备
|
||||||
|
|
||||||
|
在让 AI 操作 GitHub 之前,开发者本机必须先安装 GitHub CLI,并完成登录。
|
||||||
|
|
||||||
|
最低要求:
|
||||||
|
|
||||||
|
1. 本机已安装 `git`
|
||||||
|
2. 本机已安装 GitHub CLI,也就是 `gh`
|
||||||
|
3. 已完成 GitHub 登录
|
||||||
|
4. 当前账号对目标仓库至少有读取权限;如果要推分支、改 PR、合并 PR,还需要对应写权限
|
||||||
|
|
||||||
|
登录示例:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
gh auth login
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 AI 检查到 `gh` 不可用,或者 `gh auth status` 显示未登录、登录到错误账号、权限不足,则必须先停止并明确告诉开发者,不准假装已经完成 GitHub 操作。
|
||||||
|
|
||||||
|
## 本文适用场景
|
||||||
|
|
||||||
|
适用于下面这些任务:
|
||||||
|
|
||||||
|
- 开发新功能
|
||||||
|
- 修复 bug
|
||||||
|
- 清理陈旧逻辑
|
||||||
|
- 整理本地提交
|
||||||
|
- 发起新的 PR
|
||||||
|
- 更新已有 PR
|
||||||
|
- 在自己的 PR 下补充说明
|
||||||
|
- 在权限允许时,把自己的 PR 合并到 `dev`
|
||||||
|
|
||||||
|
不适用于:
|
||||||
|
|
||||||
|
- 版本发布
|
||||||
|
- 直接把代码合并到 `master`
|
||||||
|
- 在没看代码上下文的前提下,靠猜测生成 PR 结论
|
||||||
|
|
||||||
|
## 仓库硬性规则
|
||||||
|
|
||||||
|
1. 任何结论都不能猜,必须基于真实命令输出、真实 diff、真实代码上下文。
|
||||||
|
2. 开发基线分支只能是 `dev`。AI 不得以 `master` 作为日常开发起点,也不得把 PR 目标分支设为 `master`。
|
||||||
|
3. 发起 PR 前,必须先同步最新远端提交,确保当前分支已经对齐最新 `origin/dev`。
|
||||||
|
4. 如果发现当前 PR 的目标分支是 `master`,AI 必须立刻改为 `dev`,然后重新检查 PR 信息。
|
||||||
|
5. 如果当前工作区有无法确认归属的脏改动,AI 必须先停下来告诉开发者,不能偷偷带进本次 PR,也不能擅自删除。
|
||||||
|
6. 开发新功能时,不要为了兼容旧逻辑而保留明显无用的陈旧代码;如果确认无其他代码依赖,应一并清理。
|
||||||
|
7. 如果旧逻辑本身设计差、实现混乱或者存在 bug,AI 需要继续检查相关调用点;在不影响其他功能的前提下,可以顺手一起修正。
|
||||||
|
8. 如果改动了 SQL 文件,必须按仓库规则同步本地 MySQL:账号 `root`,密码 `123456`,数据库 `xzs`。
|
||||||
|
9. 默认不编译、不跑测试。开发完成后提醒开发者自行测试。
|
||||||
|
10. 如果是前端改动且没有新增依赖,只提醒开发者自己测试即可;如果新增了依赖并完成安装,可以自行验证能否启动,验证后要关闭启动占用的端口,并提醒开发者重新启动。
|
||||||
|
11. PR 标题、PR 正文、PR 评论都用自然中文直接表达,不要写“自动回复”“AI 分析结果如下”这种固定机器人腔。
|
||||||
|
12. 没有开发者明确授权时,AI 不得擅自合并 PR、关闭 PR、删除远端分支。
|
||||||
|
|
||||||
|
## 开发者需要提供给 AI 的信息
|
||||||
|
|
||||||
|
至少提供下面这些内容:
|
||||||
|
|
||||||
|
- 仓库本地路径
|
||||||
|
- 本次要做的功能或问题描述
|
||||||
|
- 是新任务,还是继续一个已有分支/已有 PR
|
||||||
|
- 如果已经有 PR,要提供 PR 编号
|
||||||
|
- 是否允许 AI 在自己的功能分支上执行 `rebase`
|
||||||
|
- 是否允许 AI 在最后直接发起 PR
|
||||||
|
- 如果 PR 已经审完,是否允许 AI 直接合并到 `dev`
|
||||||
|
|
||||||
|
如果这些关键信息缺失,AI 不能靠猜来补。
|
||||||
|
|
||||||
|
## 标准执行顺序
|
||||||
|
|
||||||
|
### 阶段 1:环境确认与仓库现状检查
|
||||||
|
|
||||||
|
AI 开始干活前,先执行下面这些动作:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
gh --version
|
||||||
|
gh auth status
|
||||||
|
git status --short --branch
|
||||||
|
git remote -v
|
||||||
|
git branch --show-current
|
||||||
|
git fetch origin
|
||||||
|
```
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
1. 必须先确认 `gh` 可用且已登录。
|
||||||
|
2. 必须确认当前仓库远端是正确仓库。
|
||||||
|
3. 必须确认当前工作区是否有未提交改动。
|
||||||
|
4. 不能在没看当前分支状态的情况下直接开始写代码或直接发 PR。
|
||||||
|
|
||||||
|
### 阶段 2:先对齐最新 `dev`
|
||||||
|
|
||||||
|
#### 场景 A:这是一个新任务
|
||||||
|
|
||||||
|
如果是新任务,还没开始写代码,则必须先同步最新 `dev`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git switch dev
|
||||||
|
git pull --ff-only origin dev
|
||||||
|
git switch -c <feature-branch>
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
1. 新任务必须从最新 `dev` 拉出功能分支。
|
||||||
|
2. 不允许直接在 `dev` 上开发。
|
||||||
|
3. 不允许从 `master` 拉开发分支。
|
||||||
|
|
||||||
|
#### 场景 B:这是一个已有分支上的继续开发
|
||||||
|
|
||||||
|
如果开发已经在某个功能分支上进行,则不能为了同步 `dev` 直接丢本地改动。
|
||||||
|
|
||||||
|
这时至少要先执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git fetch origin
|
||||||
|
git rev-list --left-right --count origin/dev...HEAD
|
||||||
|
git log --oneline HEAD..origin/dev
|
||||||
|
```
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
1. 必须真实判断当前分支是否已经落后于 `origin/dev`。
|
||||||
|
2. 如果当前分支落后,先继续开发可以,但在发起 PR 前必须补齐最新 `dev`。
|
||||||
|
3. 如果当前分支已经出现复杂冲突风险,AI 需要提前告诉开发者,不要拖到最后一刻再爆。
|
||||||
|
|
||||||
|
### 阶段 3:开发与本地整理
|
||||||
|
|
||||||
|
AI 开发时,必须遵守下面这些要求:
|
||||||
|
|
||||||
|
1. 先读相关代码上下文,再改代码。
|
||||||
|
2. 不能只改表面调用点,必须检查相关联的状态流、配置项、消息流、页面流程、回调流程。
|
||||||
|
3. 如果发现本次功能附近本来就有坏逻辑,而且修复不会影响其他已使用代码,可以顺手一并修正。
|
||||||
|
4. 如果为了完成新功能必须删除旧逻辑,就删除,不要为了“看起来兼容”堆陈旧代码。
|
||||||
|
5. 改完后必须自己检查:
|
||||||
|
- `git diff --stat`
|
||||||
|
- `git diff`
|
||||||
|
- 相关文件中是否残留冲突标记
|
||||||
|
- 是否混入无关改动
|
||||||
|
6. 如果改了 SQL,别忘了同步本地 MySQL `xzs`。
|
||||||
|
|
||||||
|
### 阶段 4:发起 PR 前必须再次同步最新 `dev`
|
||||||
|
|
||||||
|
这是硬规则。
|
||||||
|
|
||||||
|
无论这个分支什么时候开始开发,只要准备发起 PR,就必须再次拉取远端最新提交,并确认当前分支已经吸收了最新 `origin/dev`。
|
||||||
|
|
||||||
|
先执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git fetch origin
|
||||||
|
git log --oneline HEAD..origin/dev
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 如果 `origin/dev` 没有新提交
|
||||||
|
|
||||||
|
可以继续进入下一阶段。
|
||||||
|
|
||||||
|
#### 如果 `origin/dev` 有新提交
|
||||||
|
|
||||||
|
优先处理方式:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git rebase origin/dev
|
||||||
|
```
|
||||||
|
|
||||||
|
如果开发者明确禁止改写当前分支历史,或者这个分支已经有多人共同使用,再改用:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git merge origin/dev
|
||||||
|
```
|
||||||
|
|
||||||
|
处理规则:
|
||||||
|
|
||||||
|
1. 如果执行了 `rebase`,必须重新检查 diff,确认没有把逻辑改坏。
|
||||||
|
2. 如果执行了 `rebase` 且当前分支之前已经推到远端,后续推送时只能使用:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git push --force-with-lease origin <feature-branch>
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 不允许使用 `git push --force`。
|
||||||
|
4. 不能只把冲突标记删掉就算完,必须继续检查冲突两边的真实逻辑是否仍然一致。
|
||||||
|
|
||||||
|
### 阶段 5:提交与推送
|
||||||
|
|
||||||
|
在发起 PR 前,AI 必须先把本次改动整理干净。
|
||||||
|
|
||||||
|
至少要执行下面这些检查:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git status --short
|
||||||
|
git diff --stat origin/dev...HEAD
|
||||||
|
git diff origin/dev...HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
1. PR 中只能包含与本次任务相关的改动。
|
||||||
|
2. 不要把临时调试代码、无关格式化、无关文件重命名、构建产物一起带进 PR。
|
||||||
|
3. 提交信息必须描述真实功能结果,不要写:
|
||||||
|
- `update`
|
||||||
|
- `fix bug`
|
||||||
|
- `merge branch`
|
||||||
|
- `修改一下`
|
||||||
|
4. 推送前要确认当前分支不是 `dev`,也不是 `master`。
|
||||||
|
|
||||||
|
推送示例:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git push -u origin <feature-branch>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 阶段 6:创建或更新 PR
|
||||||
|
|
||||||
|
PR 只能指向 `dev`。
|
||||||
|
|
||||||
|
创建 PR 时使用:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
gh pr create --base dev --head <feature-branch> --title "<PR标题>" --body-file <PR正文文件>
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 PR 已经存在,则执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
gh pr view <PR_NUMBER> --json number,title,baseRefName,headRefName,state,isDraft,url
|
||||||
|
```
|
||||||
|
|
||||||
|
如果发现已有 PR 的目标分支不是 `dev`,必须改正:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
gh pr edit <PR_NUMBER> --base dev
|
||||||
|
```
|
||||||
|
|
||||||
|
改完后,再重新读取一次 PR 信息,确认:
|
||||||
|
|
||||||
|
- `baseRefName = dev`
|
||||||
|
- PR 仍是 open
|
||||||
|
- head 分支正确
|
||||||
|
|
||||||
|
#### PR 标题要求
|
||||||
|
|
||||||
|
1. 直接描述功能结果或修复结果。
|
||||||
|
2. 不要把标题写成 Git 动作描述。
|
||||||
|
3. 不要写空洞标题。
|
||||||
|
|
||||||
|
#### PR 正文要求
|
||||||
|
|
||||||
|
PR 正文直接写真实信息,建议结构如下:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 本次改动
|
||||||
|
- ...
|
||||||
|
|
||||||
|
## 风险与影响
|
||||||
|
- ...
|
||||||
|
|
||||||
|
## 测试情况
|
||||||
|
- 未运行测试,请开发者自行验证
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
1. 正文必须基于真实改动来写。
|
||||||
|
2. 不要写固定“自动回复”抬头。
|
||||||
|
3. 不要写和代码不相符的夸大表述。
|
||||||
|
|
||||||
|
### 阶段 7:PR 后续补充说明
|
||||||
|
|
||||||
|
如果 AI 需要在 PR 里补充评论、解释冲突、说明待确认点,要求如下:
|
||||||
|
|
||||||
|
1. 直接写清楚问题、原因、影响、建议。
|
||||||
|
2. 语气自然、简洁、可读。
|
||||||
|
3. 不要使用固定机器人模板。
|
||||||
|
4. 不要为了“像 AI”而加免责声明废话。
|
||||||
|
5. 评论内容必须和真实代码、真实 diff、真实冲突一致。
|
||||||
|
|
||||||
|
### 阶段 8:只有在明确授权时,才允许合并 PR
|
||||||
|
|
||||||
|
如果开发者明确要求 AI 继续合并自己的 PR,则必须先再次确认:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
gh pr view <PR_NUMBER> --json number,title,baseRefName,headRefName,state,isDraft,mergeable,mergeStateStatus,url
|
||||||
|
git fetch origin
|
||||||
|
git log --oneline HEAD..origin/dev
|
||||||
|
```
|
||||||
|
|
||||||
|
合并前必须满足:
|
||||||
|
|
||||||
|
1. PR 目标分支是 `dev`
|
||||||
|
2. PR 不是 draft
|
||||||
|
3. PR 仍然是 open
|
||||||
|
4. 当前分支已经吸收了最新 `origin/dev`
|
||||||
|
5. 没有尚未处理的明确问题
|
||||||
|
6. 开发者已经明确授权合并
|
||||||
|
|
||||||
|
如果满足以上条件,才可以执行 GitHub 合并,例如:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
gh pr merge <PR_NUMBER> --merge --delete-branch
|
||||||
|
```
|
||||||
|
|
||||||
|
限制:
|
||||||
|
|
||||||
|
1. AI 只能把自己的 PR 合并到 `dev`。
|
||||||
|
2. AI 不得把任何开发分支直接合并到 `master`。
|
||||||
|
3. 如果发现 PR 目标分支是 `master`,先改成 `dev`,再重新核对是否允许继续。
|
||||||
|
|
||||||
|
## 开发清单
|
||||||
|
|
||||||
|
开发时按下面清单自检,避免漏项:
|
||||||
|
|
||||||
|
### 第 1 阶段:开始前
|
||||||
|
|
||||||
|
- `gh` 已安装且已登录
|
||||||
|
- 当前仓库正确
|
||||||
|
- 当前工作区状态已确认
|
||||||
|
- 已明确本次任务是否是新任务还是续做
|
||||||
|
|
||||||
|
### 第 2 阶段:开发前基线
|
||||||
|
|
||||||
|
- 新任务已从最新 `dev` 拉分支
|
||||||
|
- 续做任务已确认自己相对 `origin/dev` 的落后情况
|
||||||
|
- 没有误在 `master` 或 `dev` 上直接开发
|
||||||
|
|
||||||
|
### 第 3 阶段:开发中
|
||||||
|
|
||||||
|
- 代码上下文已阅读
|
||||||
|
- 相关联逻辑已检查
|
||||||
|
- 无用旧代码已清理
|
||||||
|
- SQL 改动已同步本地数据库
|
||||||
|
|
||||||
|
### 第 4 阶段:发 PR 前
|
||||||
|
|
||||||
|
- 已再次获取最新 `origin/dev`
|
||||||
|
- 已完成 `rebase` 或 `merge`
|
||||||
|
- diff 只包含本次任务改动
|
||||||
|
- 提交信息清晰
|
||||||
|
- 当前分支不是 `dev` / `master`
|
||||||
|
|
||||||
|
### 第 5 阶段:PR 与收尾
|
||||||
|
|
||||||
|
- PR 目标分支确认是 `dev`
|
||||||
|
- PR 标题和正文与真实改动一致
|
||||||
|
- 如有评论,内容为自然中文,不用固定机器人模板
|
||||||
|
- 如果未跑测试,已明确提醒开发者测试
|
||||||
|
|
||||||
|
## 最终反馈给开发者时必须说明
|
||||||
|
|
||||||
|
AI 完成后,至少要向开发者明确反馈下面这些信息:
|
||||||
|
|
||||||
|
1. 实际执行了哪些命令和动作
|
||||||
|
2. 当前分支是否已经同步最新 `origin/dev`
|
||||||
|
3. 是否创建了新 PR,或者更新了已有 PR
|
||||||
|
4. PR 编号和链接是什么
|
||||||
|
5. PR 目标分支是否确认是 `dev`
|
||||||
|
6. 是否执行了 `rebase` 或 `merge`
|
||||||
|
7. 是否改了 SQL 并同步了本地数据库
|
||||||
|
8. 是否运行过测试;如果没跑,要明确提醒开发者自行测试
|
||||||
|
9. 是否已经合并;如果已合并,要明确说明是合并到 `dev`
|
||||||
|
|
||||||
|
## 一句话执行要求
|
||||||
|
|
||||||
|
AI 在本仓库里做开发与提 PR 时,必须按“先确认环境与当前工作区,再对齐最新 `dev`,再开发与整理改动,再次同步最新 `dev`,最后只向 `dev` 发起或更新 PR;只有在开发者明确授权时,才允许把自己的 PR 合并到 `dev`”的顺序执行,不能跳步,不能猜,不能偷懒。
|
||||||
Reference in New Issue
Block a user