/** * 메일 발송 이력 관리 서비스 (파일 기반) */ 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 { if (!fs.existsSync(SENT_MAIL_DIR)) { fs.mkdirSync(SENT_MAIL_DIR, { recursive: true, mode: 0o755 }); } } catch (error) { console.error("메일 발송 이력 디렉토리 생성 실패:", error); throw 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, mode: 0o755 }); } const filePath = path.join(SENT_MAIL_DIR, `${history.id}.json`); fs.writeFileSync(filePath, JSON.stringify(history, null, 2), { encoding: "utf-8", mode: 0o644, }); // 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 (query.onlyDeleted) { filtered = filtered.filter((h) => h.deletedAt); } else if (!query.includeDeleted) { filtered = filtered.filter((h) => !h.deletedAt); } // 상태 필터 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; } } /** * 임시 저장 (Draft) */ async saveDraft( data: Partial & { accountId: string } ): Promise { // console.log("📥 백엔드에서 받은 임시 저장 데이터:", data); const now = new Date().toISOString(); const draft: SentMailHistory = { id: data.id || uuidv4(), accountId: data.accountId, accountName: data.accountName || "", accountEmail: data.accountEmail || "", to: data.to || [], cc: data.cc, bcc: data.bcc, subject: data.subject || "", htmlContent: data.htmlContent || "", templateId: data.templateId, templateName: data.templateName, attachments: data.attachments, sentAt: data.sentAt || now, status: "draft", isDraft: true, updatedAt: now, }; // console.log("💾 저장할 draft 객체:", draft); try { if (!fs.existsSync(SENT_MAIL_DIR)) { fs.mkdirSync(SENT_MAIL_DIR, { recursive: true, mode: 0o755 }); } const filePath = path.join(SENT_MAIL_DIR, `${draft.id}.json`); fs.writeFileSync(filePath, JSON.stringify(draft, null, 2), { encoding: "utf-8", mode: 0o644, }); // console.log("💾 임시 저장:", draft.id); } catch (error) { console.error("임시 저장 실패:", error); throw error; } return draft; } /** * 임시 저장 업데이트 */ async updateDraft( id: string, data: Partial ): Promise { const existing = await this.getSentMailById(id); if (!existing) { return null; } const updated: SentMailHistory = { ...existing, ...data, id: existing.id, updatedAt: new Date().toISOString(), }; try { const filePath = path.join(SENT_MAIL_DIR, `${id}.json`); fs.writeFileSync(filePath, JSON.stringify(updated, null, 2), { encoding: "utf-8", mode: 0o644, }); // console.log("✏️ 임시 저장 업데이트:", id); return updated; } catch (error) { console.error("임시 저장 업데이트 실패:", error); return null; } } /** * 발송 이력 삭제 (Soft Delete) */ async deleteSentMail(id: string): Promise { const existing = await this.getSentMailById(id); if (!existing) { return false; } const updated: SentMailHistory = { ...existing, deletedAt: new Date().toISOString(), }; try { const filePath = path.join(SENT_MAIL_DIR, `${id}.json`); fs.writeFileSync(filePath, JSON.stringify(updated, null, 2), { encoding: "utf-8", mode: 0o644, }); // console.log("🗑️ 메일 삭제 (Soft Delete):", id); return true; } catch (error) { console.error("메일 삭제 실패:", error); return false; } } /** * 메일 복구 */ async restoreMail(id: string): Promise { const existing = await this.getSentMailById(id); if (!existing || !existing.deletedAt) { return false; } const updated: SentMailHistory = { ...existing, deletedAt: undefined, }; try { const filePath = path.join(SENT_MAIL_DIR, `${id}.json`); fs.writeFileSync(filePath, JSON.stringify(updated, null, 2), { encoding: "utf-8", mode: 0o644, }); // console.log("♻️ 메일 복구:", id); return true; } catch (error) { console.error("메일 복구 실패:", error); return false; } } /** * 메일 영구 삭제 (Hard Delete) */ async permanentlyDeleteMail(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; } } /** * 30일 이상 지난 삭제된 메일 자동 영구 삭제 */ async cleanupOldDeletedMails(): Promise { const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); let deletedCount = 0; try { if (!fs.existsSync(SENT_MAIL_DIR)) { return 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 mail: SentMailHistory = JSON.parse(content); if (mail.deletedAt) { const deletedDate = new Date(mail.deletedAt); if (deletedDate < thirtyDaysAgo) { fs.unlinkSync(filePath); deletedCount++; // console.log("🗑️ 30일 지난 메일 자동 삭제:", mail.id); } } } catch (error) { console.error(`파일 처리 실패: ${file}`, error); } } } catch (error) { console.error("자동 삭제 실패:", error); } return deletedCount; } /** * 통계 조회 */ 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();