// PLUS 邮箱收信 —— 微软 Outlook/Hotmail OAuth(refresh_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(//gi, ' ') .replace(//gi, ' ') .replace(/<[^>]+>/g, ' ') .replace(/ /g, ' ') .replace(/&/g, '&').replace(/</g, '<').replace(/>/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; } } }