Files
gpt/account-manager/node/plus-mailbox.js
T
432539 fef45134d5 Initial commit: GPT 账号注册机 (open source)
只开源「GPT 账号注册机 / 管理」(account-manager):账号列表/导入导出、检测/批量检测、
拿 RT、协议/浏览器注册等注册机能力。批量直开 / 协议支付 / 提取链接 / 直卡直开 等其它功能
不含实现代码,仅通过 iframe 内嵌外链访问。真实机密均由 .gitignore 忽略,仅提供脱敏示例配置。
2026-08-05 14:54:32 +08:00

320 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// PLUS 邮箱收信 —— 微软 Outlook/Hotmail OAuthrefresh_token + client_id
// 导入格式:email----password----clientId----refreshToken
// 收信流程:refresh_token 换 access_token → 优先 Microsoft Graph 读收件箱,失败回退 IMAP(XOAUTH2)
import tls from 'node:tls';
const TOKEN_URL = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token';
// 这些批量 Outlook 账号的 refresh_token 是按「资源级同意」(.default) 颁发的,
// 直接请求细粒度 scope(如 https://graph.microsoft.com/Mail.Read)会被拒:
// AADSTS70000 invalid_grant: one or more scopes requested are unauthorized or expired
// 因此每种传输都按候选 scope 依次尝试,取第一个成功者(.default 优先,细粒度兜底)。
const GRAPH_SCOPES = [
'https://graph.microsoft.com/.default',
'https://graph.microsoft.com/Mail.Read offline_access',
];
const IMAP_SCOPES = [
'https://outlook.office.com/IMAP.AccessAsUser.All offline_access',
'https://outlook.office.com/.default',
];
const IMAP_HOST = 'outlook.office365.com';
const IMAP_PORT = 993;
const tokenCache = new Map(); // key: clientId|refreshToken|scope -> { token, ts }
const TOKEN_TTL = 45 * 60 * 1000;
// ---------- 解析导入格式 ----------
// 每行:email----password----clientId----refreshToken(也兼容用 | 或制表符分隔的多字段)
export function parsePlus(text) {
const out = [];
const seen = new Set();
for (const raw of String(text || '').split(/\r?\n/)) {
const line = raw.trim();
if (!line) continue;
let parts;
if (line.includes('----')) parts = line.split('----');
else if (line.includes('\t')) parts = line.split('\t');
else if (line.includes('|')) parts = line.split('|');
else parts = line.split(/\s+/);
parts = parts.map((s) => s.trim()).filter(Boolean);
if (parts.length < 4) continue;
const [email, password, clientId, refreshToken] = parts;
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) continue;
const key = email.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push({ email, password, clientId, refreshToken });
}
return out;
}
// ---------- 验证码提取(与 buyer 收信口径一致:4-8 位数字优先)----------
export function extractCode(text) {
if (!text) return '';
const s = String(text);
const labeled = s.match(/(?:code|验证码|verification|otp|passcode)[^\d]{0,12}(\d{4,8})/i);
if (labeled) return labeled[1];
const m = s.match(/\b(\d{4,8})\b/);
return m ? m[1] : '';
}
// ---------- 换取 access_token(单个 scope----------
async function exchangeToken(clientId, refreshToken, scope) {
const ck = `${clientId}|${refreshToken}|${scope}`;
const cached = tokenCache.get(ck);
if (cached && Date.now() - cached.ts < TOKEN_TTL) return cached.token;
const params = new URLSearchParams();
params.set('client_id', clientId);
params.set('grant_type', 'refresh_token');
params.set('refresh_token', refreshToken);
params.set('scope', scope);
const res = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.access_token) {
const e = new Error(`换取令牌失败:${data.error || res.status} ${data.error_description ? '· ' + String(data.error_description).slice(0, 120) : ''}`);
e.status = res.status;
e.oauthError = data.error || '';
throw e;
}
tokenCache.set(ck, { token: data.access_token, ts: Date.now() });
return data.access_token;
}
// ---------- 换取 access_token(按候选 scope 回退)----------
// scopes 为候选 scope 数组,依次尝试,返回第一个成功者的 token。
// 遇到 invalid_grant/scope 类错误继续尝试下一个 scope;全部失败则抛出最后错误。
// 成功时打印命中的 scope,便于观察是哪种同意口径生效。
async function getAccessToken(clientId, refreshToken, scopes) {
const candidates = Array.isArray(scopes) ? scopes : [scopes];
let lastErr = null;
for (const scope of candidates) {
try {
const token = await exchangeToken(clientId, refreshToken, scope);
if (candidates.length > 1) {
console.log(`[plus-mailbox] 令牌换取成功 scope=${scope}`);
}
return token;
} catch (e) {
lastErr = e;
// scope 未授权/过期 → 尝试下一个候选;其它错误(RT 失效等)同样继续,
// 但若全部候选都失败则把最后的错误抛出(通常即真正原因)。
continue;
}
}
throw lastErr || new Error('换取令牌失败:无可用 scope');
}
// ---------- MIME 解码:编码字(RFC2047/ quoted-printable / base64 ----------
function decodeQP(str, isWord = false) {
let s = String(str || '');
if (isWord) s = s.replace(/_/g, ' ');
return s
.replace(/=\r?\n/g, '')
.replace(/=([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
}
function utf8(bytesStr) {
// bytesStr 为 latin1 字节串,按 UTF-8 还原
try { return Buffer.from(bytesStr, 'latin1').toString('utf8'); } catch { return bytesStr; }
}
function decodeEncodedWords(str) {
return String(str || '').replace(/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/g, (_, charset, enc, data) => {
try {
let bytes;
if (enc.toUpperCase() === 'B') bytes = Buffer.from(data, 'base64').toString('latin1');
else bytes = decodeQP(data, true);
return /utf-?8/i.test(charset) ? utf8(bytes) : bytes;
} catch { return data; }
}).replace(/\?=\s*=\?/g, ''); // 相邻编码字之间的折行分隔
}
// ---------- HTML → 纯文本 ----------
function htmlToText(html) {
return String(html || '')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&#(\d+);/g, (_, d) => String.fromCharCode(Number(d)))
.replace(/\s+/g, ' ')
.trim();
}
// ---------- Graph 读收件箱 ----------
async function fetchViaGraph(clientId, refreshToken, limit) {
const token = await getAccessToken(clientId, refreshToken, GRAPH_SCOPES);
const url = `https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages?$top=${limit}` +
`&$select=subject,from,receivedDateTime,bodyPreview,body&$orderby=receivedDateTime desc`;
const res = await fetch(url, { headers: { authorization: `Bearer ${token}` } });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const e = new Error(`Graph 读信失败:${(data.error && data.error.message) || res.status}`);
e.status = res.status;
throw e;
}
return (data.value || []).map((m) => {
const bodyText = m.body && m.body.contentType === 'html' ? htmlToText(m.body.content) : (m.body && m.body.content) || m.bodyPreview || '';
return {
from_addr: (m.from && m.from.emailAddress && m.from.emailAddress.address) || '',
subject: m.subject || '',
received_at: m.receivedDateTime || '',
body: bodyText.slice(0, 4000),
snippet: m.bodyPreview || '',
extracted_code: extractCode(`${m.subject || ''} ${m.bodyPreview || ''} ${bodyText}`),
};
});
}
// ---------- IMAP(XOAUTH2) 读收件箱(Graph 失败时回退)----------
function imapRead(email, token, limit) {
return new Promise((resolve, reject) => {
const sock = tls.connect(IMAP_PORT, IMAP_HOST, { servername: IMAP_HOST }, () => {});
let buf = '';
let stage = 'greeting';
let tag = 0;
let exists = 0;
let raw = '';
let settled = false;
const timer = setTimeout(() => finish(new Error('IMAP 超时')), 20000);
function finish(err, val) {
if (settled) return;
settled = true;
clearTimeout(timer);
try { sock.end(); sock.destroy(); } catch {}
err ? reject(err) : resolve(val);
}
const send = (cmd) => { tag++; sock.write(`a${tag} ${cmd}\r\n`); return `a${tag}`; };
sock.setEncoding('utf8');
sock.on('error', (e) => finish(new Error('IMAP ' + (e.code || e.message))));
sock.on('data', (chunk) => {
buf += chunk;
if (stage === 'greeting') {
if (!/\r\n/.test(buf)) return;
buf = '';
stage = 'auth';
const b64 = Buffer.from(`user=${email}\x01auth=Bearer ${token}\x01\x01`).toString('base64');
sock.write(`a1 AUTHENTICATE XOAUTH2 ${b64}\r\n`);
tag = 1;
return;
}
if (stage === 'auth') {
if (/^\+/m.test(buf)) { sock.write('\r\n'); } // 服务器要求继续 → 发空行以取回错误
if (/^a1 OK/mi.test(buf)) { buf = ''; stage = 'select'; send('SELECT INBOX'); return; }
if (/^a1 (NO|BAD)/mi.test(buf)) return finish(new Error('IMAP 认证失败(XOAUTH2'));
return;
}
if (stage === 'select') {
const m = buf.match(/\*\s+(\d+)\s+EXISTS/i);
if (m) exists = Number(m[1]);
if (/^a2 OK/mi.test(buf)) {
buf = '';
if (!exists) return finish(null, []);
const from = Math.max(1, exists - limit + 1);
stage = 'fetch';
send(`FETCH ${from}:${exists} (BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)] BODY.PEEK[TEXT])`);
return;
}
if (/^a2 (NO|BAD)/mi.test(buf)) return finish(new Error('IMAP SELECT 失败'));
return;
}
if (stage === 'fetch') {
raw += chunk;
if (/^a3 OK/mi.test(raw)) return finish(null, parseImapFetch(raw));
if (/^a3 (NO|BAD)/mi.test(raw)) return finish(new Error('IMAP FETCH 失败'));
}
});
// buf 只在非 fetch 阶段用;fetch 阶段用 raw
});
}
// 从 BODY[TEXT] 原文中挑出可读正文段:multipart 优先 text/plain,其次 text/html
function pickBodyPart(rawBody) {
let segment = rawBody;
const boundaryM = rawBody.match(/boundary="?([^"\r\n;]+)"?/i);
if (boundaryM) {
const bnd = boundaryM[1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const parts = rawBody.split(new RegExp('--' + bnd));
let plain = null, html = null;
for (const p of parts) {
if (!plain && /Content-Type:\s*text\/plain/i.test(p)) plain = p;
else if (!html && /Content-Type:\s*text\/html/i.test(p)) html = p;
}
segment = plain || html || rawBody;
}
const cte = (segment.match(/Content-Transfer-Encoding:\s*([\w-]+)/i) || [])[1] || '';
const charset = (segment.match(/charset=?["']?\s*([\w-]+)/i) || [])[1] || '';
const headEnd = segment.search(/\r?\n\r?\n/);
const content = headEnd >= 0 ? segment.slice(headEnd).replace(/^\s+/, '') : segment;
return { content, cte, isUtf8: /utf-?8/i.test(charset) };
}
// 极简解析:按邮件块切分,抓取 From/Subject/Date 和正文文本,并做 MIME 解码
function parseImapFetch(raw) {
const out = [];
const blocks = raw.split(/\*\s+\d+\s+FETCH/i).slice(1);
for (const b of blocks) {
const from = decodeEncodedWords((b.match(/From:\s*(.*)/i) || [])[1] || '');
const subject = decodeEncodedWords((b.match(/Subject:\s*(.*)/i) || [])[1] || '').trim();
const date = ((b.match(/Date:\s*(.*)/i) || [])[1] || '').trim();
const textIdx = b.search(/BODY\[TEXT\]/i);
let body = '';
if (textIdx >= 0) {
const after = b.slice(textIdx);
const lit = after.match(/\{(\d+)\}\r?\n/);
if (lit) {
const start = after.indexOf(lit[0]) + lit[0].length;
body = after.slice(start, start + Number(lit[1]));
}
}
// 挑选正文段(multipart 优先 text/plain),按其自身编码解码
const part = pickBodyPart(body);
let content = part.content;
if (/base64/i.test(part.cte)) {
try { content = Buffer.from(content.replace(/\s+/g, ''), 'base64').toString(part.isUtf8 ? 'utf8' : 'latin1'); } catch {}
} else if (/quoted-printable/i.test(part.cte)) {
content = decodeQP(content);
if (part.isUtf8) content = utf8(content);
} else {
content = decodeQP(content); // 兜底:常见 =XX 也一并还原
if (part.isUtf8) content = utf8(content);
}
body = htmlToText(content).slice(0, 4000);
out.push({
from_addr: from.trim().replace(/^.*<|>.*$/g, '').trim() || from.trim(),
subject,
received_at: date,
body,
snippet: body.slice(0, 160),
extracted_code: extractCode(`${subject} ${body}`),
});
}
return out.reverse();
}
// ---------- 对外统一入口 ----------
export async function fetchPlusInbox({ email, clientId, refreshToken }, limit = 15) {
if (!clientId || !refreshToken) {
const e = new Error('缺少 clientId / refreshToken');
e.status = 400;
throw e;
}
try {
const messages = await fetchViaGraph(clientId, refreshToken, limit);
return { address: email, via: 'graph', messages };
} catch (graphErr) {
// Graph 不通(scope 未授权等)→ 回退 IMAP
try {
const token = await getAccessToken(clientId, refreshToken, IMAP_SCOPES);
const messages = await imapRead(email, token, limit);
return { address: email, via: 'imap', messages };
} catch (imapErr) {
const e = new Error(`收信失败(Graph: ${graphErr.message}IMAP: ${imapErr.message}`);
e.status = graphErr.status || 502;
throw e;
}
}
}