515 lines
13 KiB
TypeScript
515 lines
13 KiB
TypeScript
/**
|
|
* 메일 발송 이력 관리 서비스 (파일 기반)
|
|
*/
|
|
|
|
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<SentMailHistory, "id" | "sentAt">
|
|
): Promise<SentMailHistory> {
|
|
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<SentMailListResponse> {
|
|
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<SentMailHistory | null> {
|
|
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<SentMailHistory> & { accountId: string }
|
|
): Promise<SentMailHistory> {
|
|
// 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<SentMailHistory>
|
|
): Promise<SentMailHistory | null> {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
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<boolean> {
|
|
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<number> {
|
|
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();
|