/** * 메일 발송 이력 관리 서비스 (파일 기반) */ import fs from "fs"; import path from "path"; import { v4 as uuidv4 } from "uuid"; import { SentMailHistory, SentMailListQuery, SentMailListResponse, AttachmentInfo, } from "../types/mailSentHistory"; // 운영 환경에서는 /app/data/mail-sent, 개발 환경에서는 프로젝트 루트의 data/mail-sent 사용 const SENT_MAIL_DIR = process.env.NODE_ENV === "production" ? "/app/data/mail-sent" : path.join(process.cwd(), "data", "mail-sent"); class MailSentHistoryService { constructor() { // 디렉토리 생성 (없으면) - try-catch로 권한 에러 방지 try { if (!fs.existsSync(SENT_MAIL_DIR)) { fs.mkdirSync(SENT_MAIL_DIR, { recursive: true }); } } catch (error) { console.error("메일 발송 이력 디렉토리 생성 실패:", error); // 디렉토리가 이미 존재하거나 권한이 없어도 서비스는 계속 실행 // 실제 파일 쓰기 시점에 에러 처리 } } /** * 발송 이력 저장 */ async saveSentMail( data: Omit ): Promise { const history: SentMailHistory = { id: uuidv4(), sentAt: new Date().toISOString(), ...data, }; try { // 디렉토리가 없으면 다시 시도 if (!fs.existsSync(SENT_MAIL_DIR)) { fs.mkdirSync(SENT_MAIL_DIR, { recursive: true }); } const filePath = path.join(SENT_MAIL_DIR, `${history.id}.json`); fs.writeFileSync(filePath, JSON.stringify(history, null, 2), "utf-8"); console.log("발송 이력 저장:", history.id); } catch (error) { console.error("발송 이력 저장 실패:", error); // 파일 저장 실패해도 history 객체는 반환 (메일 발송은 성공했으므로) } return history; } /** * 발송 이력 목록 조회 (필터링, 페이징) */ async getSentMailList( query: SentMailListQuery ): Promise { const { page = 1, limit = 20, searchTerm = "", status = "all", accountId, startDate, endDate, sortBy = "sentAt", sortOrder = "desc", } = query; // 모든 발송 이력 파일 읽기 let allHistory: SentMailHistory[] = []; try { // 디렉토리가 없으면 빈 배열 반환 if (!fs.existsSync(SENT_MAIL_DIR)) { console.warn("메일 발송 이력 디렉토리가 없습니다:", SENT_MAIL_DIR); return { items: [], total: 0, page, limit, totalPages: 0, }; } const files = fs .readdirSync(SENT_MAIL_DIR) .filter((f) => f.endsWith(".json")); for (const file of files) { try { const filePath = path.join(SENT_MAIL_DIR, file); const content = fs.readFileSync(filePath, "utf-8"); const history: SentMailHistory = JSON.parse(content); allHistory.push(history); } catch (error) { console.error(`발송 이력 파일 읽기 실패: ${file}`, error); } } } catch (error) { console.error("메일 발송 이력 조회 실패:", error); return { items: [], total: 0, page, limit, totalPages: 0, }; } // 필터링 let filtered = allHistory; // 상태 필터 if (status !== "all") { filtered = filtered.filter((h) => h.status === status); } // 계정 필터 if (accountId) { filtered = filtered.filter((h) => h.accountId === accountId); } // 날짜 필터 if (startDate) { filtered = filtered.filter((h) => h.sentAt >= startDate); } if (endDate) { filtered = filtered.filter((h) => h.sentAt <= endDate); } // 검색어 필터 (제목, 받는사람) if (searchTerm) { const term = searchTerm.toLowerCase(); filtered = filtered.filter( (h) => h.subject.toLowerCase().includes(term) || h.to.some((email) => email.toLowerCase().includes(term)) || (h.cc && h.cc.some((email) => email.toLowerCase().includes(term))) ); } // 정렬 filtered.sort((a, b) => { let aVal: any = a[sortBy]; let bVal: any = b[sortBy]; if (sortBy === "sentAt") { aVal = new Date(aVal).getTime(); bVal = new Date(bVal).getTime(); } else { aVal = aVal ? aVal.toLowerCase() : ""; bVal = bVal ? bVal.toLowerCase() : ""; } if (sortOrder === "asc") { return aVal > bVal ? 1 : -1; } else { return aVal < bVal ? 1 : -1; } }); // 페이징 const total = filtered.length; const totalPages = Math.ceil(total / limit); const start = (page - 1) * limit; const end = start + limit; const items = filtered.slice(start, end); return { items, total, page, limit, totalPages, }; } /** * 특정 발송 이력 조회 */ async getSentMailById(id: string): Promise { const filePath = path.join(SENT_MAIL_DIR, `${id}.json`); if (!fs.existsSync(filePath)) { return null; } try { const content = fs.readFileSync(filePath, "utf-8"); return JSON.parse(content) as SentMailHistory; } catch (error) { console.error("발송 이력 읽기 실패:", error); return null; } } /** * 발송 이력 삭제 */ async deleteSentMail(id: string): Promise { const filePath = path.join(SENT_MAIL_DIR, `${id}.json`); if (!fs.existsSync(filePath)) { return false; } try { fs.unlinkSync(filePath); console.log("🗑️ 발송 이력 삭제:", id); return true; } catch (error) { console.error("발송 이력 삭제 실패:", error); return false; } } /** * 통계 조회 */ async getStatistics(accountId?: string): Promise<{ totalSent: number; successCount: number; failedCount: number; todayCount: number; thisMonthCount: number; successRate: number; }> { let allHistory: SentMailHistory[] = []; try { // 디렉토리가 없으면 빈 통계 반환 if (!fs.existsSync(SENT_MAIL_DIR)) { return { totalSent: 0, successCount: 0, failedCount: 0, todayCount: 0, thisMonthCount: 0, successRate: 0, }; } const files = fs .readdirSync(SENT_MAIL_DIR) .filter((f) => f.endsWith(".json")); for (const file of files) { try { const filePath = path.join(SENT_MAIL_DIR, file); const content = fs.readFileSync(filePath, "utf-8"); const history: SentMailHistory = JSON.parse(content); // 계정 필터 if (!accountId || history.accountId === accountId) { allHistory.push(history); } } catch (error) { console.error(`발송 이력 파일 읽기 실패: ${file}`, error); } } } catch (error) { console.error("통계 조회 실패:", error); return { totalSent: 0, successCount: 0, failedCount: 0, todayCount: 0, thisMonthCount: 0, successRate: 0, }; } const now = new Date(); const todayStart = new Date( now.getFullYear(), now.getMonth(), now.getDate() ).toISOString(); const monthStart = new Date( now.getFullYear(), now.getMonth(), 1 ).toISOString(); const totalSent = allHistory.length; const successCount = allHistory.filter( (h) => h.status === "success" ).length; const failedCount = allHistory.filter((h) => h.status === "failed").length; const todayCount = allHistory.filter((h) => h.sentAt >= todayStart).length; const thisMonthCount = allHistory.filter( (h) => h.sentAt >= monthStart ).length; const successRate = totalSent > 0 ? Math.round((successCount / totalSent) * 100) : 0; return { totalSent, successCount, failedCount, todayCount, thisMonthCount, successRate, }; } } export const mailSentHistoryService = new MailSentHistoryService();