2025-10-02 18:22:58 +09:00
|
|
|
/**
|
|
|
|
|
* 메일 발송 이력 관리 서비스 (파일 기반)
|
|
|
|
|
*/
|
|
|
|
|
|
2025-10-13 16:04:13 +09:00
|
|
|
import fs from "fs";
|
|
|
|
|
import path from "path";
|
|
|
|
|
import { v4 as uuidv4 } from "uuid";
|
2025-10-02 18:22:58 +09:00
|
|
|
import {
|
|
|
|
|
SentMailHistory,
|
|
|
|
|
SentMailListQuery,
|
|
|
|
|
SentMailListResponse,
|
|
|
|
|
AttachmentInfo,
|
2025-10-13 16:04:13 +09:00
|
|
|
} from "../types/mailSentHistory";
|
2025-10-02 18:22:58 +09:00
|
|
|
|
2025-10-13 16:04:13 +09:00
|
|
|
// 운영 환경에서는 /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");
|
2025-10-02 18:22:58 +09:00
|
|
|
|
|
|
|
|
class MailSentHistoryService {
|
|
|
|
|
constructor() {
|
2025-10-13 16:04:13 +09:00
|
|
|
// 디렉토리 생성 (없으면) - try-catch로 권한 에러 방지
|
|
|
|
|
try {
|
|
|
|
|
if (!fs.existsSync(SENT_MAIL_DIR)) {
|
|
|
|
|
fs.mkdirSync(SENT_MAIL_DIR, { recursive: true });
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("메일 발송 이력 디렉토리 생성 실패:", error);
|
|
|
|
|
// 디렉토리가 이미 존재하거나 권한이 없어도 서비스는 계속 실행
|
|
|
|
|
// 실제 파일 쓰기 시점에 에러 처리
|
2025-10-02 18:22:58 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 발송 이력 저장
|
|
|
|
|
*/
|
2025-10-13 16:04:13 +09:00
|
|
|
async saveSentMail(
|
|
|
|
|
data: Omit<SentMailHistory, "id" | "sentAt">
|
|
|
|
|
): Promise<SentMailHistory> {
|
2025-10-02 18:22:58 +09:00
|
|
|
const history: SentMailHistory = {
|
|
|
|
|
id: uuidv4(),
|
|
|
|
|
sentAt: new Date().toISOString(),
|
|
|
|
|
...data,
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-13 16:04:13 +09:00
|
|
|
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 객체는 반환 (메일 발송은 성공했으므로)
|
|
|
|
|
}
|
2025-10-02 18:22:58 +09:00
|
|
|
|
|
|
|
|
return history;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 발송 이력 목록 조회 (필터링, 페이징)
|
|
|
|
|
*/
|
2025-10-13 16:04:13 +09:00
|
|
|
async getSentMailList(
|
|
|
|
|
query: SentMailListQuery
|
|
|
|
|
): Promise<SentMailListResponse> {
|
2025-10-02 18:22:58 +09:00
|
|
|
const {
|
|
|
|
|
page = 1,
|
|
|
|
|
limit = 20,
|
2025-10-13 16:04:13 +09:00
|
|
|
searchTerm = "",
|
|
|
|
|
status = "all",
|
2025-10-02 18:22:58 +09:00
|
|
|
accountId,
|
|
|
|
|
startDate,
|
|
|
|
|
endDate,
|
2025-10-13 16:04:13 +09:00
|
|
|
sortBy = "sentAt",
|
|
|
|
|
sortOrder = "desc",
|
2025-10-02 18:22:58 +09:00
|
|
|
} = query;
|
|
|
|
|
|
|
|
|
|
// 모든 발송 이력 파일 읽기
|
|
|
|
|
let allHistory: SentMailHistory[] = [];
|
|
|
|
|
|
2025-10-13 16:04:13 +09:00
|
|
|
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);
|
|
|
|
|
}
|
2025-10-02 18:22:58 +09:00
|
|
|
}
|
2025-10-13 16:04:13 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error("메일 발송 이력 조회 실패:", error);
|
|
|
|
|
return {
|
|
|
|
|
items: [],
|
|
|
|
|
total: 0,
|
|
|
|
|
page,
|
|
|
|
|
limit,
|
|
|
|
|
totalPages: 0,
|
|
|
|
|
};
|
2025-10-02 18:22:58 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 필터링
|
|
|
|
|
let filtered = allHistory;
|
|
|
|
|
|
|
|
|
|
// 상태 필터
|
2025-10-13 16:04:13 +09:00
|
|
|
if (status !== "all") {
|
2025-10-02 18:22:58 +09:00
|
|
|
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];
|
|
|
|
|
|
2025-10-13 16:04:13 +09:00
|
|
|
if (sortBy === "sentAt") {
|
2025-10-02 18:22:58 +09:00
|
|
|
aVal = new Date(aVal).getTime();
|
|
|
|
|
bVal = new Date(bVal).getTime();
|
|
|
|
|
} else {
|
2025-10-13 16:04:13 +09:00
|
|
|
aVal = aVal ? aVal.toLowerCase() : "";
|
|
|
|
|
bVal = bVal ? bVal.toLowerCase() : "";
|
2025-10-02 18:22:58 +09:00
|
|
|
}
|
|
|
|
|
|
2025-10-13 16:04:13 +09:00
|
|
|
if (sortOrder === "asc") {
|
2025-10-02 18:22:58 +09:00
|
|
|
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 {
|
2025-10-13 16:04:13 +09:00
|
|
|
const content = fs.readFileSync(filePath, "utf-8");
|
2025-10-02 18:22:58 +09:00
|
|
|
return JSON.parse(content) as SentMailHistory;
|
|
|
|
|
} catch (error) {
|
2025-10-13 16:04:13 +09:00
|
|
|
console.error("발송 이력 읽기 실패:", error);
|
2025-10-02 18:22:58 +09:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 발송 이력 삭제
|
|
|
|
|
*/
|
|
|
|
|
async deleteSentMail(id: string): Promise<boolean> {
|
|
|
|
|
const filePath = path.join(SENT_MAIL_DIR, `${id}.json`);
|
|
|
|
|
|
|
|
|
|
if (!fs.existsSync(filePath)) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
fs.unlinkSync(filePath);
|
2025-10-13 16:04:13 +09:00
|
|
|
console.log("🗑️ 발송 이력 삭제:", id);
|
2025-10-02 18:22:58 +09:00
|
|
|
return true;
|
|
|
|
|
} catch (error) {
|
2025-10-13 16:04:13 +09:00
|
|
|
console.error("발송 이력 삭제 실패:", error);
|
2025-10-02 18:22:58 +09:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 통계 조회
|
|
|
|
|
*/
|
|
|
|
|
async getStatistics(accountId?: string): Promise<{
|
|
|
|
|
totalSent: number;
|
|
|
|
|
successCount: number;
|
|
|
|
|
failedCount: number;
|
|
|
|
|
todayCount: number;
|
|
|
|
|
thisMonthCount: number;
|
|
|
|
|
successRate: number;
|
|
|
|
|
}> {
|
|
|
|
|
let allHistory: SentMailHistory[] = [];
|
|
|
|
|
|
2025-10-13 16:04:13 +09:00
|
|
|
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);
|
2025-10-02 18:22:58 +09:00
|
|
|
}
|
|
|
|
|
}
|
2025-10-13 16:04:13 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error("통계 조회 실패:", error);
|
|
|
|
|
return {
|
|
|
|
|
totalSent: 0,
|
|
|
|
|
successCount: 0,
|
|
|
|
|
failedCount: 0,
|
|
|
|
|
todayCount: 0,
|
|
|
|
|
thisMonthCount: 0,
|
|
|
|
|
successRate: 0,
|
|
|
|
|
};
|
2025-10-02 18:22:58 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const now = new Date();
|
2025-10-13 16:04:13 +09:00
|
|
|
const todayStart = new Date(
|
|
|
|
|
now.getFullYear(),
|
|
|
|
|
now.getMonth(),
|
|
|
|
|
now.getDate()
|
|
|
|
|
).toISOString();
|
|
|
|
|
const monthStart = new Date(
|
|
|
|
|
now.getFullYear(),
|
|
|
|
|
now.getMonth(),
|
|
|
|
|
1
|
|
|
|
|
).toISOString();
|
2025-10-02 18:22:58 +09:00
|
|
|
|
|
|
|
|
const totalSent = allHistory.length;
|
2025-10-13 16:04:13 +09:00
|
|
|
const successCount = allHistory.filter(
|
|
|
|
|
(h) => h.status === "success"
|
|
|
|
|
).length;
|
|
|
|
|
const failedCount = allHistory.filter((h) => h.status === "failed").length;
|
2025-10-02 18:22:58 +09:00
|
|
|
const todayCount = allHistory.filter((h) => h.sentAt >= todayStart).length;
|
2025-10-13 16:04:13 +09:00
|
|
|
const thisMonthCount = allHistory.filter(
|
|
|
|
|
(h) => h.sentAt >= monthStart
|
|
|
|
|
).length;
|
|
|
|
|
const successRate =
|
|
|
|
|
totalSent > 0 ? Math.round((successCount / totalSent) * 100) : 0;
|
2025-10-02 18:22:58 +09:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
totalSent,
|
|
|
|
|
successCount,
|
|
|
|
|
failedCount,
|
|
|
|
|
todayCount,
|
|
|
|
|
thisMonthCount,
|
|
|
|
|
successRate,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const mailSentHistoryService = new MailSentHistoryService();
|