ERP-node/backend-node/src/services/mailReceiveBasicService.ts

605 lines
19 KiB
TypeScript
Raw Normal View History

2025-10-01 17:01:31 +09:00
/**
* (Step 2 - )
* IMAP
*/
// CommonJS 모듈이므로 require 사용
const Imap = require('imap');
2025-10-01 17:01:31 +09:00
import { simpleParser } from 'mailparser';
import { mailAccountFileService } from './mailAccountFileService';
import { encryptionService } from './encryptionService';
2025-10-01 17:01:31 +09:00
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
}
2025-10-01 17:01:31 +09:00
/**
* 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<ReceivedMail[]> {
const account = await mailAccountFileService.getAccountById(accountId);
if (!account) {
throw new Error('메일 계정을 찾을 수 없습니다.');
}
// 비밀번호 복호화
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
// IMAP 설정
const accountAny = account as any;
2025-10-01 17:01:31 +09:00
const imapConfig: ImapConfig = {
user: account.email,
password: decryptedPassword,
host: accountAny.imapHost || account.smtpHost,
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
2025-10-01 17:01:31 +09:00
tls: true,
};
console.log(`📧 IMAP 연결 시도 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, 이메일: ${imapConfig.user}`);
2025-10-01 17:01:31 +09:00
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);
2025-10-01 17:01:31 +09:00
imap.once('ready', () => {
console.log('✅ IMAP 연결 성공! INBOX 열기 시도...');
clearTimeout(timeout);
2025-10-01 17:01:31 +09:00
imap.openBox('INBOX', true, (err: any, box: any) => {
if (err) {
console.error('❌ INBOX 열기 실패:', err);
2025-10-01 17:01:31 +09:00
imap.end();
return reject(err);
}
console.log(`📬 INBOX 열림 - 전체 메일 수: ${box.messages.total}`);
2025-10-01 17:01:31 +09:00
const totalMessages = box.messages.total;
if (totalMessages === 0) {
console.log('📭 메일함이 비어있습니다');
2025-10-01 17:01:31 +09:00
imap.end();
return resolve([]);
}
// 최근 메일부터 가져오기
const start = Math.max(1, totalMessages - limit + 1);
const end = totalMessages;
console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`);
2025-10-01 17:01:31 +09:00
const fetch = imap.seq.fetch(`${start}:${end}`, {
bodies: ['HEADER', 'TEXT'],
struct: true,
});
console.log(`📦 fetch 객체 생성 완료`);
let processedCount = 0;
const totalToProcess = end - start + 1;
2025-10-01 17:01:31 +09:00
fetch.on('message', (msg: any, seqno: any) => {
console.log(`📬 메일 #${seqno} 처리 시작`);
2025-10-01 17:01:31 +09:00
let header: string = '';
let body: string = '';
let attributes: any = null;
let bodiesReceived = 0;
2025-10-01 17:01:31 +09:00
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++;
2025-10-01 17:01:31 +09:00
});
});
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);
2025-10-01 17:01:31 +09:00
});
});
fetch.once('error', (fetchErr: any) => {
console.error('❌ 메일 fetch 에러:', fetchErr);
2025-10-01 17:01:31 +09:00
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);
2025-10-01 17:01:31 +09:00
});
});
});
imap.once('error', (imapErr: any) => {
console.error('❌ IMAP 연결 에러:', imapErr.message || imapErr);
clearTimeout(timeout);
2025-10-01 17:01:31 +09:00
reject(imapErr);
});
imap.once('end', () => {
console.log('🔌 IMAP 연결 종료');
});
console.log('🔗 IMAP.connect() 호출...');
2025-10-01 17:01:31 +09:00
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<MailDetail | null> {
const account = await mailAccountFileService.getAccountById(accountId);
if (!account) {
throw new Error('메일 계정을 찾을 수 없습니다.');
}
// 비밀번호 복호화
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
const accountAny = account as any;
2025-10-01 17:01:31 +09:00
const imapConfig: ImapConfig = {
user: account.email,
password: decryptedPassword,
host: accountAny.imapHost || account.smtpHost,
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
2025-10-01 17:01:31 +09:00
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;
2025-10-01 17:01:31 +09:00
const imapConfig: ImapConfig = {
user: account.email,
password: decryptedPassword,
host: accountAny.imapHost || account.smtpHost,
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
2025-10-01 17:01:31 +09:00
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;
2025-10-01 17:01:31 +09:00
const imapConfig: ImapConfig = {
user: account.email,
password: decryptedPassword,
host: accountAny.imapHost || account.smtpHost,
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
2025-10-01 17:01:31 +09:00
tls: true,
};
// console.log(`📧 IMAP 설정 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, TLS: ${imapConfig.tls}`);
2025-10-01 17:01:31 +09:00
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;
2025-10-01 17:01:31 +09:00
const imapConfig: ImapConfig = {
user: account.email,
password: decryptedPassword,
host: accountAny.imapHost || account.smtpHost,
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
2025-10-01 17:01:31 +09:00
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); // 최대 길이 제한
}
}