import { ImapFlow } from 'imapflow'; import { simpleParser } from 'mailparser'; import { encryptionService } from './encryptionService'; import { UserMailAccount } from './userMailAccountService'; import { imapConnectionPool } from './imapConnectionPool'; import { mailCache } from './mailCache'; export interface ReceivedMail { id: string; messageId: string; from: string; to: string; subject: string; date: Date; preview: string; isRead: boolean; hasAttachments: boolean; } export interface MailDetail extends ReceivedMail { htmlBody: string; textBody: string; cc?: string; bcc?: string; attachments: Array<{ filename: string; contentType: string; size: number; }>; } class UserMailImapService { async fetchMailList(account: UserMailAccount, limit: number = 50): Promise { const cacheKey = `mailList:${account.id}:INBOX:${limit}`; const cached = mailCache.get(cacheKey); if (cached) return cached; const mails = await imapConnectionPool.execute(account, async (client) => { const mailbox = await client.getMailboxLock('INBOX'); try { const status = await client.status('INBOX', { messages: true }); const total = status.messages || 0; if (total === 0) return []; const start = Math.max(1, total - limit + 1); const range = `${start}:${total}`; const result: ReceivedMail[] = []; for await (const msg of client.fetch(range, { uid: true, flags: true, envelope: true, bodyStructure: true, })) { const hasAttachments = msg.bodyStructure ? JSON.stringify(msg.bodyStructure).toLowerCase().includes('"attachment"') : false; result.push({ id: `${account.id}-imap-${msg.seq}`, messageId: msg.envelope?.messageId || `${msg.seq}`, from: msg.envelope?.from?.[0] ? `${msg.envelope.from[0].name || ''} <${msg.envelope.from[0].address}>`.trim() : 'Unknown', to: msg.envelope?.to?.[0]?.address || '', subject: msg.envelope?.subject || '(제목 없음)', date: msg.envelope?.date ? new Date(msg.envelope.date) : new Date(), preview: '', isRead: msg.flags?.has('\\Seen') || false, hasAttachments, }); } result.sort((a, b) => b.date.getTime() - a.date.getTime()); return result; } finally { mailbox.release(); } }); mailCache.set(cacheKey, mails, 60_000); return mails; } async fetchMailListStream( account: UserMailAccount, limit: number = 20, beforeSeqno: number | null = null, onMail: (mail: ReceivedMail) => void, onDone: () => void, onError: (err: Error) => void ): Promise { try { await imapConnectionPool.execute(account, async (client) => { const mailbox = await client.getMailboxLock('INBOX'); try { const status = await client.status('INBOX', { messages: true }); const total = status.messages || 0; if (total === 0) { onDone(); return; } let start: number, end: number; if (beforeSeqno !== null) { end = beforeSeqno - 1; start = Math.max(1, beforeSeqno - limit); } else { start = Math.max(1, total - limit + 1); end = total; } if (end < 1 || start > end) { onDone(); return; } for await (const msg of client.fetch(`${start}:${end}`, { uid: true, flags: true, envelope: true, bodyStructure: true, })) { const hasAttachments = msg.bodyStructure ? JSON.stringify(msg.bodyStructure).toLowerCase().includes('"attachment"') : false; onMail({ id: `${account.id}-imap-${msg.seq}`, messageId: msg.envelope?.messageId || `${msg.seq}`, from: msg.envelope?.from?.[0] ? `${msg.envelope.from[0].name || ''} <${msg.envelope.from[0].address}>`.trim() : 'Unknown', to: msg.envelope?.to?.[0]?.address || '', subject: msg.envelope?.subject || '(제목 없음)', date: msg.envelope?.date ? new Date(msg.envelope.date) : new Date(), preview: '', isRead: msg.flags?.has('\\Seen') || false, hasAttachments, }); } onDone(); } finally { mailbox.release(); } }); } catch (err) { onError(err instanceof Error ? err : new Error(String(err))); } } async getMailDetail(account: UserMailAccount, seqno: number): Promise { const cacheKey = `mailDetail:${account.id}:${seqno}`; const cached = mailCache.get(cacheKey); if (cached) return cached; const detail = await imapConnectionPool.execute(account, async (client) => { const mailbox = await client.getMailboxLock('INBOX'); try { const msg = await client.fetchOne(`${seqno}`, { uid: true, flags: true, envelope: true, bodyStructure: true, source: true, }); if (!msg) return null; const parsed = await simpleParser(msg.source as Buffer); const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from; const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to; const ccAddress = Array.isArray(parsed.cc) ? parsed.cc[0] : parsed.cc; return { id: `${account.id}-imap-${seqno}`, messageId: parsed.messageId || `${seqno}`, from: fromAddress?.text || 'Unknown', to: toAddress?.text || '', cc: ccAddress?.text, subject: parsed.subject || '(제목 없음)', date: parsed.date || new Date(), htmlBody: parsed.html || '', textBody: parsed.text || '', preview: '', isRead: msg.flags?.has('\\Seen') || false, hasAttachments: (parsed.attachments?.length || 0) > 0, attachments: (parsed.attachments || []).map((att: any) => ({ filename: att.filename || 'unnamed', contentType: att.contentType || 'application/octet-stream', size: att.size || 0, })), } as MailDetail; } finally { mailbox.release(); } }); if (detail) mailCache.set(cacheKey, detail, 300_000); return detail; } async markAsRead(account: UserMailAccount, seqno: number): Promise<{ success: boolean; message: string }> { try { await imapConnectionPool.execute(account, async (client) => { const mailbox = await client.getMailboxLock('INBOX'); try { await client.messageFlagsAdd(`${seqno}`, ['\\Seen']); } finally { mailbox.release(); } }); mailCache.invalidateByPrefix(`mailList:${account.id}:`); return { success: true, message: '읽음 처리 완료' }; } catch (err) { return { success: false, message: err instanceof Error ? err.message : '오류' }; } } async deleteMail(account: UserMailAccount, seqno: number): Promise<{ success: boolean; message: string }> { try { await imapConnectionPool.execute(account, async (client) => { // \Trash 특수 폴더 탐색 (Gmail: [Gmail]/휴지통 등) const folders = await client.list(); const trashFolder = folders.find(f => f.specialUse === '\\Trash'); const mailbox = await client.getMailboxLock('INBOX'); try { if (trashFolder) { await client.messageMove(`${seqno}`, trashFolder.path); } else { await client.messageDelete(`${seqno}`); } } finally { mailbox.release(); } }); mailCache.invalidateByPrefix(`mailList:${account.id}:`); mailCache.invalidateByPrefix(`mailDetail:${account.id}:${seqno}`); return { success: true, message: '휴지통으로 이동 완료' }; } catch (err) { return { success: false, message: err instanceof Error ? err.message : '오류' }; } } async listFolders(account: UserMailAccount): Promise> { return imapConnectionPool.execute(account, async (client) => { const folders = await client.list({ statusQuery: { unseen: true } }); return folders .filter(f => f.listed) .map(f => ({ path: f.path, name: f.name, unseen: (f as any).status?.unseen ?? 0, })); }); } async streamMailsByFolder( account: UserMailAccount, folder: string, limit: number = 20, beforeSeqno: number | null = null, onMail: (mail: ReceivedMail) => void, onDone: () => void, onError: (err: Error) => void ): Promise { try { await imapConnectionPool.execute(account, async (client) => { const mailbox = await client.getMailboxLock(folder); try { const status = await client.status(folder, { messages: true }); const total = status.messages || 0; if (total === 0) { onDone(); return; } let start: number, end: number; if (beforeSeqno !== null) { end = beforeSeqno - 1; start = Math.max(1, beforeSeqno - limit); } else { start = Math.max(1, total - limit + 1); end = total; } if (end < 1 || start > end) { onDone(); return; } for await (const msg of client.fetch(`${start}:${end}`, { uid: true, flags: true, envelope: true, bodyStructure: true, })) { const hasAttachments = msg.bodyStructure ? JSON.stringify(msg.bodyStructure).toLowerCase().includes('"attachment"') : false; onMail({ id: `${account.id}-imap-${msg.seq}`, messageId: msg.envelope?.messageId || `${msg.seq}`, from: msg.envelope?.from?.[0] ? `${msg.envelope.from[0].name || ''} <${msg.envelope.from[0].address}>`.trim() : 'Unknown', to: msg.envelope?.to?.[0]?.address || '', subject: msg.envelope?.subject || '(제목 없음)', date: msg.envelope?.date ? new Date(msg.envelope.date) : new Date(), preview: '', isRead: msg.flags?.has('\\Seen') || false, hasAttachments, }); } onDone(); } finally { mailbox.release(); } }); } catch (err) { onError(err instanceof Error ? err : new Error(String(err))); } } async moveMail(account: UserMailAccount, seqno: number, targetFolder: string): Promise<{ success: boolean; message: string }> { try { await imapConnectionPool.execute(account, async (client) => { const mailbox = await client.getMailboxLock('INBOX'); try { await client.messageMove(`${seqno}`, targetFolder); } finally { mailbox.release(); } }); mailCache.invalidateByPrefix(`mailList:${account.id}:`); return { success: true, message: '이동 완료' }; } catch (err) { return { success: false, message: err instanceof Error ? err.message : '오류' }; } } async downloadAttachment( account: UserMailAccount, seqno: number, partId: string, res: import('express').Response, folder: string = 'INBOX', filenameHint?: string ): Promise { await imapConnectionPool.execute(account, async (client) => { const mailbox = await client.getMailboxLock(folder); try { const { meta, content } = await client.download(`${seqno}`, partId); const rawFilename = filenameHint || (meta as any).filename || 'attachment'; const encodedFilename = encodeURIComponent(rawFilename); res.setHeader('Content-Disposition', `attachment; filename="${rawFilename}"; filename*=UTF-8''${encodedFilename}`); res.setHeader('Content-Type', (meta as any).contentType || 'application/octet-stream'); if ((meta as any).size) res.setHeader('Content-Length', String((meta as any).size)); await require('stream/promises').pipeline(content, res); } finally { mailbox.release(); } }); } async getAttachmentList(account: UserMailAccount, seqno: number, folder: string = 'INBOX'): Promise> { return imapConnectionPool.execute(account, async (client) => { const mailbox = await client.getMailboxLock(folder); try { const msg = await client.fetchOne(`${seqno}`, { bodyStructure: true }); if (!msg || !msg.bodyStructure) return []; const result: Array<{ partId: string; filename: string; contentType: string; size: number }> = []; function walk(node: any, part: string) { const filename = node.parameters?.name || node.dispositionParameters?.filename; if (filename && node.type !== 'text' && node.type !== 'multipart') { result.push({ partId: node.part || part, filename, contentType: `${node.type}/${node.subtype}`, size: node.size || 0, }); } if (node.childNodes) node.childNodes.forEach((c: any, i: number) => walk(c, `${part}.${i + 1}`)); } walk(msg.bodyStructure, '1'); return result; } finally { mailbox.release(); } }); } async testConnection(account: UserMailAccount): Promise<{ success: boolean; message: string }> { const decryptedPassword = encryptionService.decrypt(account.password); 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 }, }); try { await client.connect(); await client.logout(); return { success: true, message: 'IMAP 연결 성공' }; } catch (err) { let message = '연결 실패'; if (err instanceof Error) { const imapErr = err as any; const raw = imapErr.response || imapErr.responseCode || imapErr.cause?.message || err.message; const r = String(raw).toLowerCase(); if (r.includes('authentication') || r.includes('invalid credentials') || r.includes('authenticationfailed') || r.includes('login failed')) { message = '인증 실패: 이메일 주소 또는 비밀번호가 올바르지 않습니다.'; } else if (r.includes('econnrefused') || r.includes('connection refused')) { message = '연결 거부: 호스트 또는 포트를 확인하세요.'; } else if (r.includes('enotfound') || r.includes('getaddrinfo')) { message = '호스트를 찾을 수 없습니다. IMAP 주소를 확인하세요.'; } else if (r.includes('timeout') || r.includes('etimedout')) { message = '연결 시간 초과: 서버가 응답하지 않습니다.'; } else if (r.includes('self signed') || r.includes('certificate')) { message = 'SSL 인증서 오류가 발생했습니다.'; } else if (r.includes('econnreset')) { message = '연결이 강제로 끊겼습니다. TLS/SSL 설정을 확인하세요.'; } else { message = raw; } } return { success: false, message }; } } } export const userMailImapService = new UserMailImapService();