/** * 메일 수신 서비스 (Step 2 - 기본 구현) * IMAP 연결 및 메일 목록 조회 */ // CommonJS 모듈이므로 require 사용 const Imap = require('imap'); import { simpleParser } from 'mailparser'; import { mailAccountFileService } from './mailAccountFileService'; import { encryptionService } from './encryptionService'; import fs from 'fs/promises'; import path from 'path'; 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; // HTML 본문 textBody: string; // 텍스트 본문 cc?: string; bcc?: string; attachments: Array<{ filename: string; contentType: string; size: number; }>; } export interface ImapConfig { user: string; password: string; host: string; port: number; tls: boolean; } export class MailReceiveBasicService { private attachmentsDir: string; constructor() { this.attachmentsDir = path.join(process.cwd(), 'uploads', 'mail-attachments'); this.ensureDirectoryExists(); } private async ensureDirectoryExists() { try { await fs.access(this.attachmentsDir); } catch { await fs.mkdir(this.attachmentsDir, { recursive: true }); } } /** * SMTP 포트에서 IMAP 포트 추론 */ private inferImapPort(smtpPort: number, imapPort?: number): number { if (imapPort) return imapPort; if (smtpPort === 465 || smtpPort === 587) { return 993; // IMAPS (SSL/TLS) } else if (smtpPort === 25) { return 143; // IMAP (no encryption) } return 993; // 기본값: IMAPS } /** * IMAP 연결 생성 */ private createImapConnection(config: ImapConfig): any { return new (Imap as any)({ user: config.user, password: config.password, host: config.host, port: config.port, tls: config.tls, tlsOptions: { rejectUnauthorized: false }, }); } /** * 메일 계정으로 받은 메일 목록 조회 */ async fetchMailList(accountId: string, limit: number = 50): Promise { const account = await mailAccountFileService.getAccountById(accountId); if (!account) { throw new Error('메일 계정을 찾을 수 없습니다.'); } // 비밀번호 복호화 const decryptedPassword = encryptionService.decrypt(account.smtpPassword); // IMAP 설정 const accountAny = account as any; const imapConfig: ImapConfig = { user: account.email, password: decryptedPassword, host: accountAny.imapHost || account.smtpHost, port: this.inferImapPort(account.smtpPort, accountAny.imapPort), tls: true, }; console.log(`📧 IMAP 연결 시도 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, 이메일: ${imapConfig.user}`); return new Promise((resolve, reject) => { const imap = this.createImapConnection(imapConfig); const mails: ReceivedMail[] = []; // 30초 타임아웃 설정 const timeout = setTimeout(() => { console.error('❌ IMAP 연결 타임아웃 (30초)'); imap.end(); reject(new Error('IMAP 연결 타임아웃')); }, 30000); imap.once('ready', () => { console.log('✅ IMAP 연결 성공! INBOX 열기 시도...'); clearTimeout(timeout); imap.openBox('INBOX', true, (err: any, box: any) => { if (err) { console.error('❌ INBOX 열기 실패:', err); imap.end(); return reject(err); } console.log(`📬 INBOX 열림 - 전체 메일 수: ${box.messages.total}`); const totalMessages = box.messages.total; if (totalMessages === 0) { console.log('📭 메일함이 비어있습니다'); imap.end(); return resolve([]); } // 최근 메일부터 가져오기 const start = Math.max(1, totalMessages - limit + 1); const end = totalMessages; console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`); const fetch = imap.seq.fetch(`${start}:${end}`, { bodies: ['HEADER', 'TEXT'], struct: true, }); console.log(`📦 fetch 객체 생성 완료`); let processedCount = 0; const totalToProcess = end - start + 1; fetch.on('message', (msg: any, seqno: any) => { console.log(`📬 메일 #${seqno} 처리 시작`); let header: string = ''; let body: string = ''; let attributes: any = null; let bodiesReceived = 0; msg.on('body', (stream: any, info: any) => { let buffer = ''; stream.on('data', (chunk: any) => { buffer += chunk.toString('utf8'); }); stream.once('end', () => { if (info.which === 'HEADER') { header = buffer; } else { body = buffer; } bodiesReceived++; }); }); msg.once('attributes', (attrs: any) => { attributes = attrs; }); msg.once('end', () => { // body 데이터를 모두 받을 때까지 대기 const waitForBodies = setInterval(async () => { if (bodiesReceived >= 2 || (header && body)) { clearInterval(waitForBodies); try { const parsed = await simpleParser(header + '\r\n\r\n' + body); const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from; const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to; const mail: ReceivedMail = { id: `${accountId}-${seqno}`, messageId: parsed.messageId || `${seqno}`, from: fromAddress?.text || 'Unknown', to: toAddress?.text || '', subject: parsed.subject || '(제목 없음)', date: parsed.date || new Date(), preview: this.extractPreview(parsed.text || parsed.html || ''), isRead: attributes?.flags?.includes('\\Seen') || false, hasAttachments: (parsed.attachments?.length || 0) > 0, }; mails.push(mail); console.log(`✓ 메일 #${seqno} 파싱 완료 (${mails.length}/${totalToProcess})`); processedCount++; } catch (parseError) { console.error(`메일 #${seqno} 파싱 오류:`, parseError); processedCount++; } } }, 50); }); }); fetch.once('error', (fetchErr: any) => { console.error('❌ 메일 fetch 에러:', fetchErr); imap.end(); reject(fetchErr); }); fetch.once('end', () => { console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`); // 모든 메일 처리가 완료될 때까지 대기 const checkComplete = setInterval(() => { console.log(`⏳ 대기 중 - 처리됨: ${processedCount}/${totalToProcess}, 메일: ${mails.length}개`); if (processedCount >= totalToProcess) { clearInterval(checkComplete); console.log(`✅ 메일 가져오기 완료 - 총 ${mails.length}개`); imap.end(); // 최신 메일이 위로 오도록 정렬 mails.sort((a, b) => b.date.getTime() - a.date.getTime()); console.log(`📤 메일 목록 반환: ${mails.length}개`); resolve(mails); } }, 100); // 최대 10초 대기 setTimeout(() => { clearInterval(checkComplete); console.log(`⚠️ 타임아웃 - 부분 반환: ${mails.length}/${totalToProcess}개`); imap.end(); mails.sort((a, b) => b.date.getTime() - a.date.getTime()); resolve(mails); }, 10000); }); }); }); imap.once('error', (imapErr: any) => { console.error('❌ IMAP 연결 에러:', imapErr.message || imapErr); clearTimeout(timeout); reject(imapErr); }); imap.once('end', () => { console.log('🔌 IMAP 연결 종료'); }); console.log('🔗 IMAP.connect() 호출...'); imap.connect(); }); } /** * 텍스트 미리보기 추출 (최대 150자) */ private extractPreview(text: string): string { // HTML 태그 제거 const plainText = text.replace(/<[^>]*>/g, ''); // 공백 정리 const cleaned = plainText.replace(/\s+/g, ' ').trim(); // 최대 150자 return cleaned.length > 150 ? cleaned.substring(0, 150) + '...' : cleaned; } /** * 메일 상세 조회 */ async getMailDetail(accountId: string, seqno: number): Promise { const account = await mailAccountFileService.getAccountById(accountId); if (!account) { throw new Error('메일 계정을 찾을 수 없습니다.'); } // 비밀번호 복호화 const decryptedPassword = encryptionService.decrypt(account.smtpPassword); const accountAny = account as any; const imapConfig: ImapConfig = { user: account.email, password: decryptedPassword, host: accountAny.imapHost || account.smtpHost, port: this.inferImapPort(account.smtpPort, accountAny.imapPort), tls: true, }; return new Promise((resolve, reject) => { const imap = this.createImapConnection(imapConfig); imap.once('ready', () => { imap.openBox('INBOX', false, (err: any, box: any) => { if (err) { imap.end(); return reject(err); } const fetch = imap.seq.fetch(`${seqno}:${seqno}`, { bodies: '', struct: true, }); let mailDetail: MailDetail | null = null; fetch.on('message', (msg: any, seqnum: any) => { msg.on('body', (stream: any, info: any) => { let buffer = ''; stream.on('data', (chunk: any) => { buffer += chunk.toString('utf8'); }); stream.once('end', async () => { try { const parsed = await simpleParser(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; const bccAddress = Array.isArray(parsed.bcc) ? parsed.bcc[0] : parsed.bcc; mailDetail = { id: `${accountId}-${seqnum}`, messageId: parsed.messageId || `${seqnum}`, from: fromAddress?.text || 'Unknown', to: toAddress?.text || '', cc: ccAddress?.text, bcc: bccAddress?.text, subject: parsed.subject || '(제목 없음)', date: parsed.date || new Date(), htmlBody: parsed.html || '', textBody: parsed.text || '', preview: this.extractPreview(parsed.text || parsed.html || ''), isRead: true, // 조회 시 읽음으로 표시 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, })), }; } catch (parseError) { console.error('메일 파싱 오류:', parseError); } }); }); }); fetch.once('error', (fetchErr: any) => { imap.end(); reject(fetchErr); }); fetch.once('end', () => { imap.end(); resolve(mailDetail); }); }); }); imap.once('error', (imapErr: any) => { reject(imapErr); }); imap.connect(); }); } /** * 메일을 읽음으로 표시 */ async markAsRead(accountId: string, seqno: number): Promise<{ success: boolean; message: string }> { const account = await mailAccountFileService.getAccountById(accountId); if (!account) { throw new Error('메일 계정을 찾을 수 없습니다.'); } // 비밀번호 복호화 const decryptedPassword = encryptionService.decrypt(account.smtpPassword); const accountAny = account as any; const imapConfig: ImapConfig = { user: account.email, password: decryptedPassword, host: accountAny.imapHost || account.smtpHost, port: this.inferImapPort(account.smtpPort, accountAny.imapPort), tls: true, }; return new Promise((resolve, reject) => { const imap = this.createImapConnection(imapConfig); imap.once('ready', () => { imap.openBox('INBOX', false, (err: any, box: any) => { if (err) { imap.end(); return reject(err); } imap.seq.addFlags(seqno, ['\\Seen'], (flagErr: any) => { imap.end(); if (flagErr) { reject(flagErr); } else { resolve({ success: true, message: '메일을 읽음으로 표시했습니다.', }); } }); }); }); imap.once('error', (imapErr: any) => { reject(imapErr); }); imap.connect(); }); } /** * IMAP 연결 테스트 */ async testImapConnection(accountId: string): Promise<{ success: boolean; message: string }> { try { const account = await mailAccountFileService.getAccountById(accountId); if (!account) { throw new Error('메일 계정을 찾을 수 없습니다.'); } // 비밀번호 복호화 const decryptedPassword = encryptionService.decrypt(account.smtpPassword); // console.log(`🔐 IMAP 테스트 - 이메일: ${account.email}, 비밀번호 길이: ${decryptedPassword.length}`); const accountAny = account as any; const imapConfig: ImapConfig = { user: account.email, password: decryptedPassword, host: accountAny.imapHost || account.smtpHost, port: this.inferImapPort(account.smtpPort, accountAny.imapPort), tls: true, }; // console.log(`📧 IMAP 설정 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, TLS: ${imapConfig.tls}`); return new Promise((resolve, reject) => { const imap = this.createImapConnection(imapConfig); imap.once('ready', () => { imap.end(); resolve({ success: true, message: 'IMAP 연결 성공', }); }); imap.once('error', (err: any) => { reject(err); }); // 타임아웃 설정 (10초) const timeout = setTimeout(() => { imap.end(); reject(new Error('연결 시간 초과')); }, 10000); imap.once('ready', () => { clearTimeout(timeout); }); imap.connect(); }); } catch (error) { return { success: false, message: error instanceof Error ? error.message : '알 수 없는 오류', }; } } /** * 첨부파일 다운로드 */ async downloadAttachment( accountId: string, seqno: number, attachmentIndex: number ): Promise<{ filePath: string; filename: string; contentType: string } | null> { const account = await mailAccountFileService.getAccountById(accountId); if (!account) { throw new Error('메일 계정을 찾을 수 없습니다.'); } // 비밀번호 복호화 const decryptedPassword = encryptionService.decrypt(account.smtpPassword); const accountAny = account as any; const imapConfig: ImapConfig = { user: account.email, password: decryptedPassword, host: accountAny.imapHost || account.smtpHost, port: this.inferImapPort(account.smtpPort, accountAny.imapPort), tls: true, }; return new Promise((resolve, reject) => { const imap = this.createImapConnection(imapConfig); imap.once('ready', () => { imap.openBox('INBOX', true, (err: any, box: any) => { if (err) { imap.end(); return reject(err); } const fetch = imap.seq.fetch(`${seqno}:${seqno}`, { bodies: '', struct: true, }); let attachmentResult: { filePath: string; filename: string; contentType: string } | null = null; fetch.on('message', (msg: any, seqnum: any) => { msg.on('body', (stream: any, info: any) => { let buffer = ''; stream.on('data', (chunk: any) => { buffer += chunk.toString('utf8'); }); stream.once('end', async () => { try { const parsed = await simpleParser(buffer); if (parsed.attachments && parsed.attachments[attachmentIndex]) { const attachment = parsed.attachments[attachmentIndex]; // 안전한 파일명 생성 const safeFilename = this.sanitizeFilename( attachment.filename || `attachment-${Date.now()}` ); const timestamp = Date.now(); const filename = `${accountId}-${seqno}-${timestamp}-${safeFilename}`; const filePath = path.join(this.attachmentsDir, filename); // 파일 저장 await fs.writeFile(filePath, attachment.content); attachmentResult = { filePath, filename: attachment.filename || 'unnamed', contentType: attachment.contentType || 'application/octet-stream', }; } } catch (parseError) { console.error('첨부파일 파싱 오류:', parseError); } }); }); }); fetch.once('error', (fetchErr: any) => { imap.end(); reject(fetchErr); }); fetch.once('end', () => { imap.end(); resolve(attachmentResult); }); }); }); imap.once('error', (imapErr: any) => { reject(imapErr); }); imap.connect(); }); } /** * 파일명 정제 (안전한 파일명 생성) */ private sanitizeFilename(filename: string): string { return filename .replace(/[^a-zA-Z0-9가-힣.\-_]/g, '_') .replace(/_{2,}/g, '_') .substring(0, 200); // 최대 길이 제한 } }