- IMAP 계정 등록/수정/삭제/연결테스트 - SSE 스트리밍으로 메일 목록 로드 (폴더별 지원) - 메일 상세 조회, 읽음 처리, 삭제(휴지통 이동), 폴더 이동 - 첨부파일 다운로드 (ReadableStream 진행바) - SMTP 발송, 답장, 전달 - imapConnectionPool, mailCache 서비스 - encryptionService Node 22+ 호환 수정 - authMiddleware query token 지원 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
110 lines
3.1 KiB
TypeScript
110 lines
3.1 KiB
TypeScript
import { ImapFlow } from 'imapflow';
|
|
import { encryptionService } from './encryptionService';
|
|
import { UserMailAccount } from './userMailAccountService';
|
|
|
|
interface PoolEntry {
|
|
client: ImapFlow;
|
|
accountId: number;
|
|
lastUsed: number;
|
|
busy: boolean;
|
|
queue: Array<{ fn: (client: ImapFlow) => Promise<any>; resolve: (v: any) => void; reject: (e: any) => void }>;
|
|
}
|
|
|
|
class ImapConnectionPool {
|
|
private pool = new Map<number, PoolEntry>();
|
|
private readonly maxIdleMs = 300_000;
|
|
|
|
constructor() {
|
|
setInterval(() => this.cleanupIdle(), 60_000);
|
|
process.on('SIGTERM', () => this.destroyAll());
|
|
process.on('SIGINT', () => this.destroyAll());
|
|
}
|
|
|
|
async execute<T>(account: UserMailAccount, fn: (client: ImapFlow) => Promise<T>): Promise<T> {
|
|
const decryptedPassword = encryptionService.decrypt(account.password);
|
|
let entry = this.pool.get(account.id);
|
|
|
|
if (entry && !entry.client.usable) {
|
|
this.pool.delete(account.id);
|
|
entry = undefined;
|
|
}
|
|
|
|
if (!entry) {
|
|
const client = new ImapFlow({
|
|
host: account.host,
|
|
port: account.port,
|
|
secure: account.useTls,
|
|
auth: { user: account.username, pass: decryptedPassword },
|
|
logger: false as any,
|
|
tls: { rejectUnauthorized: false },
|
|
});
|
|
await client.connect();
|
|
entry = { client, accountId: account.id, lastUsed: Date.now(), busy: false, queue: [] };
|
|
this.pool.set(account.id, entry);
|
|
|
|
client.on('close', () => {
|
|
const e = this.pool.get(account.id);
|
|
if (e && e.client === client) {
|
|
this.pool.delete(account.id);
|
|
for (const pending of e.queue) pending.reject(new Error('IMAP 연결이 끊겼습니다'));
|
|
e.queue = [];
|
|
}
|
|
});
|
|
}
|
|
|
|
if (entry.busy) {
|
|
return new Promise<T>((resolve, reject) => {
|
|
entry!.queue.push({ fn: fn as any, resolve, reject });
|
|
});
|
|
}
|
|
|
|
return this.runWithEntry(entry, fn);
|
|
}
|
|
|
|
private async runWithEntry<T>(entry: PoolEntry, fn: (client: ImapFlow) => Promise<T>): Promise<T> {
|
|
entry.busy = true;
|
|
entry.lastUsed = Date.now();
|
|
try {
|
|
return await fn(entry.client);
|
|
} catch (err) {
|
|
if (!entry.client.usable) {
|
|
this.pool.delete(entry.accountId);
|
|
}
|
|
throw err;
|
|
} finally {
|
|
entry.busy = false;
|
|
if (entry.queue.length > 0) {
|
|
const next = entry.queue.shift()!;
|
|
this.runWithEntry(entry, next.fn).then(next.resolve).catch(next.reject);
|
|
}
|
|
}
|
|
}
|
|
|
|
private cleanupIdle() {
|
|
const now = Date.now();
|
|
for (const [id, entry] of this.pool.entries()) {
|
|
if (!entry.busy && entry.queue.length === 0 && now - entry.lastUsed > this.maxIdleMs) {
|
|
try { entry.client.logout(); } catch {}
|
|
this.pool.delete(id);
|
|
}
|
|
}
|
|
}
|
|
|
|
destroyByAccount(accountId: number) {
|
|
const entry = this.pool.get(accountId);
|
|
if (entry) {
|
|
try { entry.client.logout(); } catch {}
|
|
this.pool.delete(accountId);
|
|
}
|
|
}
|
|
|
|
destroyAll() {
|
|
for (const entry of this.pool.values()) {
|
|
try { entry.client.logout(); } catch {}
|
|
}
|
|
this.pool.clear();
|
|
}
|
|
}
|
|
|
|
export const imapConnectionPool = new ImapConnectionPool();
|