메일관리 시스템 구현 완료
This commit is contained in:
503
backend-node/src/services/mailReceiveBasicService.ts
Normal file
503
backend-node/src/services/mailReceiveBasicService.ts
Normal file
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* 메일 수신 서비스 (Step 2 - 기본 구현)
|
||||
* IMAP 연결 및 메일 목록 조회
|
||||
*/
|
||||
|
||||
import * as Imap from 'imap';
|
||||
import { simpleParser } from 'mailparser';
|
||||
import { mailAccountFileService } from './mailAccountFileService';
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 imapConfig: ImapConfig = {
|
||||
user: account.email,
|
||||
password: account.smtpPassword, // 이미 복호화됨
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort === 587 ? 993 : account.smtpPort, // SMTP 587 -> IMAP 993
|
||||
tls: true,
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const imap = this.createImapConnection(imapConfig);
|
||||
const mails: ReceivedMail[] = [];
|
||||
|
||||
imap.once('ready', () => {
|
||||
imap.openBox('INBOX', true, (err: any, box: any) => {
|
||||
if (err) {
|
||||
imap.end();
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
const totalMessages = box.messages.total;
|
||||
if (totalMessages === 0) {
|
||||
imap.end();
|
||||
return resolve([]);
|
||||
}
|
||||
|
||||
// 최근 메일부터 가져오기
|
||||
const start = Math.max(1, totalMessages - limit + 1);
|
||||
const end = totalMessages;
|
||||
|
||||
const fetch = imap.seq.fetch(`${start}:${end}`, {
|
||||
bodies: ['HEADER', 'TEXT'],
|
||||
struct: true,
|
||||
});
|
||||
|
||||
fetch.on('message', (msg: any, seqno: any) => {
|
||||
let header: string = '';
|
||||
let body: string = '';
|
||||
let attributes: any = null;
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
msg.once('attributes', (attrs: any) => {
|
||||
attributes = attrs;
|
||||
});
|
||||
|
||||
msg.once('end', async () => {
|
||||
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);
|
||||
} catch (parseError) {
|
||||
console.error('메일 파싱 오류:', parseError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
fetch.once('error', (fetchErr: any) => {
|
||||
imap.end();
|
||||
reject(fetchErr);
|
||||
});
|
||||
|
||||
fetch.once('end', () => {
|
||||
imap.end();
|
||||
// 최신 메일이 위로 오도록 정렬
|
||||
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||
resolve(mails);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
imap.once('error', (imapErr: any) => {
|
||||
reject(imapErr);
|
||||
});
|
||||
|
||||
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 imapConfig: ImapConfig = {
|
||||
user: account.email,
|
||||
password: account.smtpPassword,
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort === 587 ? 993 : account.smtpPort,
|
||||
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 imapConfig: ImapConfig = {
|
||||
user: account.email,
|
||||
password: account.smtpPassword,
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort === 587 ? 993 : account.smtpPort,
|
||||
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 imapConfig: ImapConfig = {
|
||||
user: account.email,
|
||||
password: account.smtpPassword,
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort === 587 ? 993 : account.smtpPort,
|
||||
tls: true,
|
||||
};
|
||||
|
||||
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 imapConfig: ImapConfig = {
|
||||
user: account.email,
|
||||
password: account.smtpPassword,
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort === 587 ? 993 : account.smtpPort,
|
||||
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); // 최대 길이 제한
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user