diff options
Diffstat (limited to 'lib/mail/service.ts')
| -rw-r--r-- | lib/mail/service.ts | 380 |
1 files changed, 380 insertions, 0 deletions
diff --git a/lib/mail/service.ts b/lib/mail/service.ts new file mode 100644 index 00000000..cbd02953 --- /dev/null +++ b/lib/mail/service.ts @@ -0,0 +1,380 @@ +'use server';
+
+import fs from 'fs';
+import path from 'path';
+import handlebars from 'handlebars';
+import i18next from 'i18next';
+import resourcesToBackend from 'i18next-resources-to-backend';
+import { getOptions } from '@/i18n/settings';
+
+// Types
+export interface TemplateFile {
+ name: string;
+ content: string;
+ lastModified: string;
+ path: string;
+}
+
+interface UpdateTemplateRequest {
+ name: string;
+ content: string;
+}
+
+interface TemplateValidationResult {
+ isValid: boolean;
+ errors: string[];
+ warnings: string[];
+}
+
+// Configuration
+const baseDir = path.join(process.cwd(), 'lib', 'mail');
+const templatesDir = path.join(baseDir, 'templates');
+
+let initialized = false;
+
+function getFilePath(name: string): string {
+ const fileName = name.endsWith('.hbs') ? name : `${name}.hbs`;
+ return path.join(templatesDir, fileName);
+}
+
+// Initialization
+async function initializeAsync(): Promise<void> {
+ if (initialized) return;
+
+ try {
+ // i18next 초기화 (서버 사이드)
+ if (!i18next.isInitialized) {
+ await i18next
+ .use(resourcesToBackend((language: string, namespace: string) =>
+ import(`@/i18n/locales/${language}/${namespace}.json`)
+ ))
+ .init(getOptions());
+ }
+
+ // Handlebars 헬퍼 등록
+ registerHandlebarsHelpers();
+ initialized = true;
+ } catch (error) {
+ console.error('Failed to initialize TemplateService:', error);
+ // 초기화 실패해도 헬퍼는 등록
+ registerHandlebarsHelpers();
+ initialized = true;
+ }
+}
+
+async function ensureInitialized(): Promise<void> {
+ if (initialized) return;
+ await initializeAsync();
+}
+
+function registerHandlebarsHelpers(): void {
+ // i18n 번역 헬퍼
+ handlebars.registerHelper('t', function(key: string, options: { hash?: Record<string, unknown> }) {
+ return i18next.t(key, options.hash || {});
+ });
+
+ // 날짜 포맷 헬퍼
+ handlebars.registerHelper('formatDate', function(date: Date | string, format?: string) {
+ if (!date) return '';
+ const d = new Date(date);
+ if (isNaN(d.getTime())) return '';
+
+ if (!format || format === 'date') {
+ return d.toISOString().split('T')[0];
+ }
+
+ if (format === 'datetime') {
+ return d.toLocaleString('ko-KR', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+ }
+
+ return d.toLocaleDateString('ko-KR');
+ });
+
+ // 숫자 포맷 헬퍼
+ handlebars.registerHelper('formatNumber', function(number: number | string) {
+ if (typeof number === 'string') {
+ number = parseFloat(number);
+ }
+ if (isNaN(number)) return '';
+ return number.toLocaleString('ko-KR');
+ });
+
+ // 조건부 렌더링 헬퍼
+ handlebars.registerHelper('ifEquals', function(this: unknown, arg1: unknown, arg2: unknown, options: { fn: (context: unknown) => string; inverse: (context: unknown) => string }) {
+ return (arg1 === arg2) ? options.fn(this) : options.inverse(this);
+ });
+
+ // 배열 길이 확인 헬퍼
+ handlebars.registerHelper('ifArrayLength', function(this: unknown, array: unknown[], length: number, options: { fn: (context: unknown) => string; inverse: (context: unknown) => string }) {
+ return (Array.isArray(array) && array.length === length) ? options.fn(this) : options.inverse(this);
+ });
+}
+
+// Validation
+function validateTemplate(content: string): TemplateValidationResult {
+ const errors: string[] = [];
+ const warnings: string[] = [];
+
+ try {
+ // Handlebars 문법 검사
+ handlebars.compile(content);
+ } catch (error) {
+ if (error instanceof Error) {
+ errors.push(`Handlebars 문법 오류: ${error.message}`);
+ }
+ }
+
+ // 일반적인 문제 확인
+ if (content.trim().length === 0) {
+ errors.push('템플릿 내용이 비어있습니다.');
+ }
+
+ // 위험한 패턴 확인
+ if (content.includes('<script>')) {
+ warnings.push('스크립트 태그가 포함되어 있습니다. 보안에 주의하세요.');
+ }
+
+ return {
+ isValid: errors.length === 0,
+ errors,
+ warnings
+ };
+}
+
+// Core Functions
+async function getTemplateList(): Promise<TemplateFile[]> {
+ try {
+ if (!fs.existsSync(templatesDir)) {
+ return [];
+ }
+
+ const files = fs.readdirSync(templatesDir).filter(file => file.endsWith('.hbs'));
+ const allTemplates: TemplateFile[] = [];
+
+ for (const file of files) {
+ const filePath = path.join(templatesDir, file);
+
+ try {
+ const stats = fs.statSync(filePath);
+ const content = fs.readFileSync(filePath, 'utf8');
+
+ allTemplates.push({
+ name: file.replace('.hbs', ''),
+ content,
+ lastModified: stats.mtime.toISOString(),
+ path: filePath
+ });
+ } catch (fileError) {
+ console.error(`❌ 파일 읽기 실패: ${filePath}`, fileError);
+ }
+ }
+
+ return allTemplates.sort((a, b) => a.name.localeCompare(b.name));
+ } catch (error) {
+ console.error('Error getting template list:', error);
+ throw new Error('템플릿 목록을 가져오는데 실패했습니다.');
+ }
+}
+
+async function getTemplate(name: string): Promise<TemplateFile | null> {
+ try {
+ const filePath = getFilePath(name);
+
+ if (!fs.existsSync(filePath)) {
+ return null;
+ }
+
+ const stats = fs.statSync(filePath);
+ const content = fs.readFileSync(filePath, 'utf8');
+
+ return {
+ name: name.replace('.hbs', ''),
+ content,
+ lastModified: stats.mtime.toISOString(),
+ path: filePath
+ };
+ } catch (error) {
+ console.error(`❌ 템플릿 조회 실패 ${name}:`, error);
+ throw new Error(`템플릿 ${name}을 가져오는데 실패했습니다.`);
+ }
+}
+
+async function updateTemplate(request: UpdateTemplateRequest): Promise<TemplateFile> {
+ try {
+ const { name, content } = request;
+
+ // 템플릿 유효성 검사
+ const validation = validateTemplate(content);
+ if (!validation.isValid) {
+ throw new Error(`템플릿 유효성 검사 실패: ${validation.errors.join(', ')}`);
+ }
+
+ const filePath = getFilePath(name);
+
+ if (!fs.existsSync(filePath)) {
+ throw new Error(`템플릿 ${name}이 존재하지 않습니다.`);
+ }
+
+ fs.writeFileSync(filePath, content, 'utf8');
+
+ const stats = fs.statSync(filePath);
+
+ return {
+ name: name.replace('.hbs', ''),
+ content,
+ lastModified: stats.mtime.toISOString(),
+ path: filePath
+ };
+ } catch (error) {
+ console.error('Error updating template:', error);
+ if (error instanceof Error) {
+ throw error;
+ }
+ throw new Error('템플릿 수정에 실패했습니다.');
+ }
+}
+
+async function searchTemplates(query: string): Promise<TemplateFile[]> {
+ try {
+ const allTemplates = await getTemplateList();
+ const lowerQuery = query.toLowerCase();
+
+ return allTemplates.filter(template =>
+ template.name.toLowerCase().includes(lowerQuery) ||
+ template.content.toLowerCase().includes(lowerQuery)
+ );
+ } catch (error) {
+ console.error('Error searching templates:', error);
+ throw new Error('템플릿 검색에 실패했습니다.');
+ }
+}
+
+async function previewTemplate(
+ name: string,
+ data?: Record<string, unknown>
+): Promise<string> {
+ try {
+ // 초기화 대기
+ await ensureInitialized();
+
+ const template = await getTemplate(name);
+ if (!template) {
+ throw new Error(`템플릿 ${name}이 존재하지 않습니다.`);
+ }
+
+ // 템플릿 컴파일
+ const compiledTemplate = handlebars.compile(template.content);
+ const content = compiledTemplate(data || {});
+
+ return content;
+ } catch (error) {
+ console.error('Error previewing template:', error);
+ throw new Error('템플릿 미리보기 생성에 실패했습니다.');
+ }
+}
+
+// Server Actions
+export async function getTemplatesAction(search?: string) {
+ try {
+ let templates;
+
+ if (search) {
+ templates = await searchTemplates(search);
+ } else {
+ templates = await getTemplateList();
+ }
+
+ return {
+ success: true,
+ data: templates
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '템플릿 목록을 가져오는데 실패했습니다.'
+ };
+ }
+}
+
+export async function getTemplateAction(name: string) {
+ try {
+ const template = await getTemplate(name);
+
+ if (!template) {
+ return {
+ success: false,
+ error: '템플릿을 찾을 수 없습니다.'
+ };
+ }
+
+ return {
+ success: true,
+ data: template
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '템플릿을 가져오는데 실패했습니다.'
+ };
+ }
+}
+
+export async function updateTemplateAction(name: string, content: string) {
+ try {
+ if (!content) {
+ return {
+ success: false,
+ error: '템플릿 내용이 필요합니다.'
+ };
+ }
+
+ const template = await updateTemplate({
+ name,
+ content
+ });
+
+ return {
+ success: true,
+ data: template
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '템플릿 수정에 실패했습니다.'
+ };
+ }
+}
+
+export async function previewTemplateAction(
+ name: string,
+ data?: Record<string, unknown>
+) {
+ try {
+ if (!name) {
+ return {
+ success: false,
+ error: '템플릿 이름이 필요합니다.'
+ };
+ }
+
+ const result = await previewTemplate(name, data);
+
+ return {
+ success: true,
+ data: {
+ html: result
+ }
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '미리보기 생성에 실패했습니다.'
+ };
+ }
+}
\ No newline at end of file |
