149 lines
5.5 KiB
JavaScript
149 lines
5.5 KiB
JavaScript
'use strict';
|
|
|
|
const tls = require('tls');
|
|
const db = require('../src/core/database');
|
|
|
|
const IMAP_HOST = 'imap.gmail.com';
|
|
const IMAP_PORT = 993;
|
|
|
|
function quoteImap(value) {
|
|
return `"${String(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
}
|
|
|
|
function decodeMimeWords(value) {
|
|
return String(value || '').replace(/=\?([^?]+)\?([bq])\?([^?]*)\?=/gi, (_, charset, encoding, data) => {
|
|
try {
|
|
if (encoding.toLowerCase() === 'b') return Buffer.from(data, 'base64').toString('utf8');
|
|
const bytes = data.replace(/_/g, ' ').replace(/=([0-9a-f]{2})/gi, (_m, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
return Buffer.from(bytes, 'binary').toString(/^utf-?8$/i.test(charset) ? 'utf8' : 'latin1');
|
|
} catch (_) {
|
|
return data;
|
|
}
|
|
});
|
|
}
|
|
|
|
function headerValue(block, name) {
|
|
const unfolded = String(block || '').replace(/\r?\n[\t ]+/g, ' ');
|
|
const match = unfolded.match(new RegExp(`(?:^|\\r?\\n)${name}:\\s*([^\\r\\n]*)`, 'i'));
|
|
return match ? decodeMimeWords(match[1].trim()) : '';
|
|
}
|
|
|
|
function recentHeaders(fetchResponse) {
|
|
const blocks = String(fetchResponse || '').split(/(?=\* \d+ FETCH \()/i);
|
|
return blocks.map(block => {
|
|
const uid = (block.match(/\bUID\s+(\d+)/i) || [])[1];
|
|
if (!uid) return null;
|
|
return {
|
|
uid: Number(uid),
|
|
from: headerValue(block, 'From'),
|
|
subject: headerValue(block, 'Subject'),
|
|
date: headerValue(block, 'Date'),
|
|
};
|
|
}).filter(Boolean);
|
|
}
|
|
|
|
async function connectImap(email, appPassword) {
|
|
return new Promise((resolve, reject) => {
|
|
const socket = tls.connect({
|
|
host: IMAP_HOST,
|
|
port: IMAP_PORT,
|
|
servername: IMAP_HOST,
|
|
rejectUnauthorized: true,
|
|
});
|
|
socket.setTimeout(30000);
|
|
|
|
let buffer = '';
|
|
let greetingSeen = false;
|
|
let settled = false;
|
|
const waiters = new Map();
|
|
|
|
const finishError = error => {
|
|
if (settled) return;
|
|
settled = true;
|
|
try { socket.destroy(); } catch (_) {}
|
|
reject(error);
|
|
};
|
|
|
|
socket.on('timeout', () => finishError(new Error('IMAP连接或命令响应超时')));
|
|
socket.on('error', finishError);
|
|
socket.on('data', chunk => {
|
|
buffer += chunk.toString('utf8');
|
|
if (!greetingSeen && /^\* (?:OK|PREAUTH)/m.test(buffer)) greetingSeen = true;
|
|
for (const [tag, waiter] of waiters) {
|
|
const match = buffer.match(new RegExp(`(?:^|\\r\\n)${tag} (OK|NO|BAD)(?: |\\r\\n)`, 'i'));
|
|
if (!match) continue;
|
|
const end = match.index + match[0].length;
|
|
const response = buffer.slice(waiter.start, end);
|
|
waiters.delete(tag);
|
|
if (match[1].toUpperCase() === 'OK') waiter.resolve(response);
|
|
else waiter.reject(new Error(`IMAP命令失败:${match[1].toUpperCase()}`));
|
|
}
|
|
});
|
|
|
|
socket.once('secureConnect', async () => {
|
|
try {
|
|
const greetingDeadline = Date.now() + 10000;
|
|
while (!greetingSeen && Date.now() < greetingDeadline) {
|
|
await new Promise(r => setTimeout(r, 50));
|
|
}
|
|
if (!greetingSeen) throw new Error('未收到 Gmail IMAP 欢迎响应');
|
|
|
|
let sequence = 0;
|
|
const command = text => new Promise((commandResolve, commandReject) => {
|
|
const tag = `A${String(++sequence).padStart(3, '0')}`;
|
|
waiters.set(tag, { start: buffer.length, resolve: commandResolve, reject: commandReject });
|
|
socket.write(`${tag} ${text}\r\n`);
|
|
});
|
|
|
|
await command(`LOGIN ${quoteImap(email)} ${quoteImap(appPassword)}`);
|
|
const statusResponse = await command('STATUS INBOX (MESSAGES UNSEEN)');
|
|
await command('EXAMINE INBOX');
|
|
const searchResponse = await command('UID SEARCH ALL');
|
|
const searchLine = (searchResponse.match(/\* SEARCH([^\r\n]*)/i) || [])[1] || '';
|
|
const uids = searchLine.trim().split(/\s+/).filter(value => /^\d+$/.test(value));
|
|
const latestUids = uids.slice(-3);
|
|
let headers = [];
|
|
if (latestUids.length) {
|
|
const fetchResponse = await command(`UID FETCH ${latestUids.join(',')} (UID BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE)])`);
|
|
headers = recentHeaders(fetchResponse).sort((a, b) => b.uid - a.uid);
|
|
}
|
|
await command('LOGOUT').catch(() => {});
|
|
|
|
const statusItems = (statusResponse.match(/\* STATUS\s+"?INBOX"?\s+\(([^)]*)\)/i) || [])[1] || '';
|
|
const messageCount = (statusItems.match(/\bMESSAGES\s+(\d+)/i) || [])[1];
|
|
const unseenCount = (statusItems.match(/\bUNSEEN\s+(\d+)/i) || [])[1];
|
|
settled = true;
|
|
socket.end();
|
|
resolve({
|
|
ok: true,
|
|
host: IMAP_HOST,
|
|
port: IMAP_PORT,
|
|
tlsAuthorized: socket.authorized,
|
|
messages: messageCount ? Number(messageCount) : uids.length,
|
|
unseen: unseenCount ? Number(unseenCount) : null,
|
|
recentHeaders: headers,
|
|
});
|
|
} catch (error) {
|
|
finishError(error);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
const email = String(process.argv[2] || '').trim();
|
|
if (!email) throw new Error('用法: node scripts/test_gmail_imap.js <email>');
|
|
db.initDB();
|
|
const saved = db.getGmailAppPassword(email);
|
|
if (!saved || !saved.app_password) throw new Error('数据库中没有该邮箱的16位应用专用密码');
|
|
const result = await connectImap(email, saved.app_password);
|
|
console.log(JSON.stringify({ email, ...result }, null, 2));
|
|
}
|
|
|
|
main().catch(error => {
|
|
console.error(JSON.stringify({ ok: false, message: error.message }));
|
|
process.exitCode = 1;
|
|
});
|
|
|
|
module.exports = { connectImap, _private: { decodeMimeWords, recentHeaders } };
|