2025-10-13 16:18:54 +09:00
|
|
|
import fs from "fs/promises";
|
|
|
|
|
import path from "path";
|
2025-10-01 17:01:31 +09:00
|
|
|
|
|
|
|
|
// MailComponent 인터페이스 정의
|
|
|
|
|
export interface MailComponent {
|
|
|
|
|
id: string;
|
|
|
|
|
type: "text" | "button" | "image" | "spacer";
|
|
|
|
|
content?: string;
|
|
|
|
|
text?: string;
|
|
|
|
|
url?: string;
|
|
|
|
|
src?: string;
|
|
|
|
|
height?: number;
|
|
|
|
|
styles?: Record<string, string>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// QueryConfig 인터페이스 정의 (사용하지 않지만 타입 호환성 유지)
|
|
|
|
|
export interface QueryConfig {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
sql: string;
|
|
|
|
|
parameters: any[];
|
|
|
|
|
}
|
2025-10-01 16:15:53 +09:00
|
|
|
|
|
|
|
|
export interface MailTemplate {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
subject: string;
|
|
|
|
|
components: MailComponent[];
|
|
|
|
|
queryConfig?: {
|
|
|
|
|
queries: QueryConfig[];
|
|
|
|
|
};
|
|
|
|
|
recipientConfig?: {
|
2025-10-13 16:18:54 +09:00
|
|
|
type: "query" | "manual";
|
2025-10-01 16:15:53 +09:00
|
|
|
emailField?: string;
|
|
|
|
|
nameField?: string;
|
|
|
|
|
queryId?: string;
|
|
|
|
|
manualList?: Array<{ email: string; name?: string }>;
|
|
|
|
|
};
|
|
|
|
|
category?: string;
|
|
|
|
|
createdAt: string;
|
|
|
|
|
updatedAt: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class MailTemplateFileService {
|
|
|
|
|
private templatesDir: string;
|
|
|
|
|
|
|
|
|
|
constructor() {
|
2025-10-13 16:18:54 +09:00
|
|
|
// 운영 환경에서는 /app/uploads/mail-templates, 개발 환경에서는 프로젝트 루트
|
|
|
|
|
this.templatesDir =
|
|
|
|
|
process.env.NODE_ENV === "production"
|
|
|
|
|
? "/app/uploads/mail-templates"
|
|
|
|
|
: path.join(process.cwd(), "uploads", "mail-templates");
|
2025-10-01 16:15:53 +09:00
|
|
|
this.ensureDirectoryExists();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2025-10-13 16:18:54 +09:00
|
|
|
* 템플릿 디렉토리 생성 (없으면) - try-catch로 권한 에러 방지
|
2025-10-01 16:15:53 +09:00
|
|
|
*/
|
|
|
|
|
private async ensureDirectoryExists() {
|
|
|
|
|
try {
|
|
|
|
|
await fs.access(this.templatesDir);
|
|
|
|
|
} catch {
|
2025-10-13 16:18:54 +09:00
|
|
|
try {
|
|
|
|
|
await fs.mkdir(this.templatesDir, { recursive: true });
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error("메일 템플릿 디렉토리 생성 실패:", error);
|
|
|
|
|
}
|
2025-10-01 16:15:53 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 템플릿 파일 경로 생성
|
|
|
|
|
*/
|
|
|
|
|
private getTemplatePath(id: string): string {
|
|
|
|
|
return path.join(this.templatesDir, `${id}.json`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 모든 템플릿 목록 조회
|
|
|
|
|
*/
|
|
|
|
|
async getAllTemplates(): Promise<MailTemplate[]> {
|
|
|
|
|
await this.ensureDirectoryExists();
|
2025-10-13 16:18:54 +09:00
|
|
|
|
2025-10-01 16:15:53 +09:00
|
|
|
try {
|
|
|
|
|
const files = await fs.readdir(this.templatesDir);
|
2025-10-13 16:18:54 +09:00
|
|
|
const jsonFiles = files.filter((f) => f.endsWith(".json"));
|
2025-10-01 16:15:53 +09:00
|
|
|
|
|
|
|
|
const templates = await Promise.all(
|
|
|
|
|
jsonFiles.map(async (file) => {
|
|
|
|
|
const content = await fs.readFile(
|
|
|
|
|
path.join(this.templatesDir, file),
|
2025-10-13 16:18:54 +09:00
|
|
|
"utf-8"
|
2025-10-01 16:15:53 +09:00
|
|
|
);
|
|
|
|
|
return JSON.parse(content) as MailTemplate;
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 최신순 정렬
|
2025-10-13 16:18:54 +09:00
|
|
|
return templates.sort(
|
|
|
|
|
(a, b) =>
|
|
|
|
|
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
2025-10-01 16:15:53 +09:00
|
|
|
);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 특정 템플릿 조회
|
|
|
|
|
*/
|
|
|
|
|
async getTemplateById(id: string): Promise<MailTemplate | null> {
|
|
|
|
|
try {
|
2025-10-13 16:18:54 +09:00
|
|
|
const content = await fs.readFile(this.getTemplatePath(id), "utf-8");
|
2025-10-01 16:15:53 +09:00
|
|
|
return JSON.parse(content);
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 템플릿 생성
|
|
|
|
|
*/
|
|
|
|
|
async createTemplate(
|
2025-10-13 16:18:54 +09:00
|
|
|
data: Omit<MailTemplate, "id" | "createdAt" | "updatedAt">
|
2025-10-01 16:15:53 +09:00
|
|
|
): Promise<MailTemplate> {
|
|
|
|
|
const id = `template-${Date.now()}`;
|
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
|
|
|
|
|
|
const template: MailTemplate = {
|
|
|
|
|
...data,
|
|
|
|
|
id,
|
|
|
|
|
createdAt: now,
|
|
|
|
|
updatedAt: now,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await fs.writeFile(
|
|
|
|
|
this.getTemplatePath(id),
|
|
|
|
|
JSON.stringify(template, null, 2),
|
2025-10-13 16:18:54 +09:00
|
|
|
"utf-8"
|
2025-10-01 16:15:53 +09:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return template;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 템플릿 수정
|
|
|
|
|
*/
|
|
|
|
|
async updateTemplate(
|
|
|
|
|
id: string,
|
2025-10-13 16:18:54 +09:00
|
|
|
data: Partial<Omit<MailTemplate, "id" | "createdAt">>
|
2025-10-01 16:15:53 +09:00
|
|
|
): Promise<MailTemplate | null> {
|
2025-10-13 15:17:34 +09:00
|
|
|
try {
|
|
|
|
|
const existing = await this.getTemplateById(id);
|
|
|
|
|
if (!existing) {
|
|
|
|
|
// console.error(`❌ 템플릿을 찾을 수 없음: ${id}`);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const updated: MailTemplate = {
|
|
|
|
|
...existing,
|
|
|
|
|
...data,
|
|
|
|
|
id: existing.id,
|
|
|
|
|
createdAt: existing.createdAt,
|
|
|
|
|
updatedAt: new Date().toISOString(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// console.log(`📝 템플릿 저장 시도: ${id}, 크기: ${JSON.stringify(updated).length} bytes`);
|
|
|
|
|
|
|
|
|
|
await fs.writeFile(
|
|
|
|
|
this.getTemplatePath(id),
|
|
|
|
|
JSON.stringify(updated, null, 2),
|
2025-10-13 16:18:54 +09:00
|
|
|
"utf-8"
|
2025-10-13 15:17:34 +09:00
|
|
|
);
|
2025-10-01 16:15:53 +09:00
|
|
|
|
2025-10-13 15:17:34 +09:00
|
|
|
// console.log(`✅ 템플릿 저장 성공: ${id}`);
|
|
|
|
|
return updated;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// console.error(`❌ 템플릿 저장 실패: ${id}`, error);
|
|
|
|
|
throw error; // 에러를 컨트롤러로 전달
|
|
|
|
|
}
|
2025-10-01 16:15:53 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 템플릿 삭제
|
|
|
|
|
*/
|
|
|
|
|
async deleteTemplate(id: string): Promise<boolean> {
|
|
|
|
|
try {
|
|
|
|
|
await fs.unlink(this.getTemplatePath(id));
|
|
|
|
|
return true;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 템플릿을 HTML로 렌더링
|
|
|
|
|
*/
|
|
|
|
|
renderTemplateToHtml(components: MailComponent[]): string {
|
2025-10-13 16:18:54 +09:00
|
|
|
let html =
|
|
|
|
|
'<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">';
|
2025-10-01 16:15:53 +09:00
|
|
|
|
2025-10-13 16:18:54 +09:00
|
|
|
components.forEach((comp) => {
|
2025-10-01 16:15:53 +09:00
|
|
|
const styles = Object.entries(comp.styles || {})
|
|
|
|
|
.map(([key, value]) => `${this.camelToKebab(key)}: ${value}`)
|
2025-10-13 16:18:54 +09:00
|
|
|
.join("; ");
|
2025-10-01 16:15:53 +09:00
|
|
|
|
|
|
|
|
switch (comp.type) {
|
2025-10-13 16:18:54 +09:00
|
|
|
case "text":
|
|
|
|
|
html += `<div style="${styles}">${comp.content || ""}</div>`;
|
2025-10-01 16:15:53 +09:00
|
|
|
break;
|
2025-10-13 16:18:54 +09:00
|
|
|
case "button":
|
2025-10-01 16:15:53 +09:00
|
|
|
html += `<div style="text-align: center; ${styles}">
|
2025-10-13 16:18:54 +09:00
|
|
|
<a href="${comp.url || "#"}"
|
2025-10-01 16:15:53 +09:00
|
|
|
style="display: inline-block; padding: 12px 24px; text-decoration: none;
|
2025-10-13 16:18:54 +09:00
|
|
|
background-color: ${comp.styles?.backgroundColor || "#007bff"};
|
|
|
|
|
color: ${comp.styles?.color || "#fff"};
|
2025-10-01 16:15:53 +09:00
|
|
|
border-radius: 4px;">
|
2025-10-13 16:18:54 +09:00
|
|
|
${comp.text || "Button"}
|
2025-10-01 16:15:53 +09:00
|
|
|
</a>
|
|
|
|
|
</div>`;
|
|
|
|
|
break;
|
2025-10-13 16:18:54 +09:00
|
|
|
case "image":
|
2025-10-01 16:15:53 +09:00
|
|
|
html += `<div style="${styles}">
|
2025-10-13 16:18:54 +09:00
|
|
|
<img src="${comp.src || ""}" alt="" style="max-width: 100%; height: auto;" />
|
2025-10-01 16:15:53 +09:00
|
|
|
</div>`;
|
|
|
|
|
break;
|
2025-10-13 16:18:54 +09:00
|
|
|
case "spacer":
|
2025-10-01 16:15:53 +09:00
|
|
|
html += `<div style="height: ${comp.height || 20}px;"></div>`;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-10-13 16:18:54 +09:00
|
|
|
html += "</div>";
|
2025-10-01 16:15:53 +09:00
|
|
|
return html;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* camelCase를 kebab-case로 변환
|
|
|
|
|
*/
|
|
|
|
|
private camelToKebab(str: string): string {
|
2025-10-13 16:18:54 +09:00
|
|
|
return str.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
2025-10-01 16:15:53 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 카테고리별 템플릿 조회
|
|
|
|
|
*/
|
|
|
|
|
async getTemplatesByCategory(category: string): Promise<MailTemplate[]> {
|
|
|
|
|
const allTemplates = await this.getAllTemplates();
|
2025-10-13 16:18:54 +09:00
|
|
|
return allTemplates.filter((t) => t.category === category);
|
2025-10-01 16:15:53 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 템플릿 검색
|
|
|
|
|
*/
|
|
|
|
|
async searchTemplates(keyword: string): Promise<MailTemplate[]> {
|
|
|
|
|
const allTemplates = await this.getAllTemplates();
|
|
|
|
|
const lowerKeyword = keyword.toLowerCase();
|
2025-10-13 16:18:54 +09:00
|
|
|
|
|
|
|
|
return allTemplates.filter(
|
|
|
|
|
(t) =>
|
|
|
|
|
t.name.toLowerCase().includes(lowerKeyword) ||
|
|
|
|
|
t.subject.toLowerCase().includes(lowerKeyword) ||
|
|
|
|
|
t.category?.toLowerCase().includes(lowerKeyword)
|
2025-10-01 16:15:53 +09:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const mailTemplateFileService = new MailTemplateFileService();
|