diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-21 07:19:52 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-21 07:19:52 +0000 |
| commit | 9da494b0e3bbe7b513521d0915510fe9ee376b8b (patch) | |
| tree | f936f69626bf2808ac409ce7cad97433465b3672 /lib/mail | |
| parent | e275618ff8a1ce6977d3e2567d943edb941897f9 (diff) | |
(대표님, 최겸) 작업사항 - 이메일 템플릿, 메일링, 기술영업 요구사항 반영
Diffstat (limited to 'lib/mail')
| -rw-r--r-- | lib/mail/sendEmail.ts | 302 | ||||
| -rw-r--r-- | lib/mail/service.ts | 380 |
2 files changed, 275 insertions, 407 deletions
diff --git a/lib/mail/sendEmail.ts b/lib/mail/sendEmail.ts index 3b358ea8..408f6e40 100644 --- a/lib/mail/sendEmail.ts +++ b/lib/mail/sendEmail.ts @@ -1,72 +1,320 @@ import { useTranslation } from '@/i18n'; -import { transporter, loadTemplate } from './mailer'; +import { transporter } from './mailer'; +import db from '@/db/db'; +import { templateDetailView } from '@/db/schema'; +import { eq } from 'drizzle-orm'; import handlebars from 'handlebars'; +import fs from 'fs'; +import path from 'path'; interface SendEmailOptions { to: string; - subject: string; - template: string; // 템플릿 파일명(확장자 제외) - context: Record<string, any>; // 템플릿에 주입할 데이터 - cc?: string | string[]; // cc 필드 추가 - 단일 이메일 또는 이메일 배열 - from?: string; // from 필드 추가 - 옵셔널 + subject?: string; // 🆕 이제 선택적, 템플릿에서 가져올 수 있음 + template: string; // 템플릿 slug + context: Record<string, any>; + cc?: string | string[]; + from?: string; attachments?: { - // NodeMailer "Attachment" 타입 - filename?: string - path?: string - content?: Buffer | string - // ... - }[] + filename?: string; + path?: string; + content?: Buffer | string; + }[]; +} + +interface TemplateValidationError { + field: string; + message: string; +} + +interface TemplateRenderResult { + subject: string; + html: string; + validationErrors: TemplateValidationError[]; } +// 템플릿 캐시 (성능 최적화) +const templateCache = new Map<string, { + subject: string; + content: string; + variables: any[]; + cachedAt: number; +}>(); + +const CACHE_DURATION = 5 * 60 * 1000; // 5분 + export async function sendEmail({ to, - subject, + subject, // 이제 선택적 매개변수 template, context, - cc, // cc 매개변수 추가 - from, // from 매개변수 추가 + cc, + from, attachments = [] }: SendEmailOptions) { try { // i18n 설정 const { t, i18n } = await useTranslation(context.language ?? "en", "translation"); - // t 헬퍼를 언어별로 동적으로 재등록 (기존 헬퍼 덮어쓰기) - // 헬퍼가 이미 등록되어 있더라도 안전하게 재등록 - handlebars.unregisterHelper('t'); // 기존 헬퍼 제거 + // Handlebars 헬퍼 등록 + handlebars.unregisterHelper('t'); handlebars.registerHelper("t", function (key: string, options: any) { - // 여기서 i18n은 로컬 인스턴스 return i18n.t(key, options?.hash || {}); }); - // 템플릿 데이터에 i18n 인스턴스와 번역 함수 추가 + // 템플릿 데이터 준비 const templateData = { ...context, t: (key: string, options?: any) => i18n.t(key, options || {}), i18n: i18n }; - // 템플릿 컴파일 및 HTML 생성 - const html = loadTemplate(template, templateData); + // 템플릿에서 subject와 content 모두 로드 + const { subject: renderedSubject, html, validationErrors } = + await loadAndRenderTemplate(template, templateData, subject); + + if (validationErrors.length > 0) { + console.warn(`템플릿 검증 경고 (${template}):`, validationErrors); + } - // from 값 설정 - 매개변수가 있으면 사용, 없으면 기본값 사용 + // from 주소 설정 const fromAddress = from || `"${process.env.Email_From_Name}" <${process.env.Email_From_Address}>`; // 이메일 발송 const result = await transporter.sendMail({ from: fromAddress, to, - cc, // cc 필드 추가 - subject, + cc, + subject: renderedSubject, // 템플릿에서 렌더링된 subject 사용 html, attachments }); - console.log(`이메일 발송 성공: ${to}`, result.messageId); + console.log(`이메일 발송 성공: ${to}`, { + messageId: result.messageId, + subject: renderedSubject, + template + }); + return result; - + } catch (error) { console.error(`이메일 발송 실패: ${to}`, error); throw error; } +} + +async function loadAndRenderTemplate( + templateSlug: string, + data: Record<string, unknown>, + fallbackSubject?: string +): Promise<TemplateRenderResult> { + try { + // 캐시 확인 + const cached = templateCache.get(templateSlug); + const now = Date.now(); + + let templateInfo; + + if (cached && (now - cached.cachedAt) < CACHE_DURATION) { + templateInfo = cached; + } else { + // 데이터베이스에서 템플릿 조회 + const templates = await db + .select() + .from(templateDetailView) + .where(eq(templateDetailView.slug, templateSlug)) + .limit(1); + + if (templates.length === 0) { + // 폴백: 파일 기반 템플릿 시도 + return await loadFileBasedTemplate(templateSlug, data, fallbackSubject); + } + + const template = templates[0]; + + // 비활성화된 템플릿 확인 + if (!template.isActive) { + throw new Error(`Template '${templateSlug}' is inactive`); + } + + templateInfo = { + subject: template.subject, + content: template.content, + variables: template.variables as any[], + cachedAt: now + }; + + // 캐시 저장 + templateCache.set(templateSlug, templateInfo); + } + + // 변수 검증 + const validationErrors = validateTemplateVariables(templateInfo.variables, data); + + // Subject와 Content 모두 Handlebars로 렌더링 + const subjectTemplate = handlebars.compile(templateInfo.subject); + const contentTemplate = handlebars.compile(templateInfo.content); + + const subject = subjectTemplate(data); + const html = contentTemplate(data); + + return { subject, html, validationErrors }; + + } catch (error) { + console.error(`템플릿 로드 실패: ${templateSlug}`, error); + + // 최종 폴백: 파일 기반 템플릿 시도 + try { + return await loadFileBasedTemplate(templateSlug, data, fallbackSubject); + } catch (fallbackError) { + throw new Error(`Template loading failed for '${templateSlug}': ${fallbackError}`); + } + } +} + +// 변수 검증 함수 (기존과 동일) +function validateTemplateVariables( + templateVariables: any[], + providedData: Record<string, unknown> +): TemplateValidationError[] { + const errors: TemplateValidationError[] = []; + + for (const variable of templateVariables) { + const { variableName, isRequired, variableType } = variable; + const value = providedData[variableName]; + + // 필수 변수 검증 + if (isRequired && (value === undefined || value === null || value === '')) { + errors.push({ + field: variableName, + message: `Required variable '${variableName}' is missing` + }); + continue; + } + + // 타입 검증 (값이 제공된 경우에만) + if (value !== undefined && value !== null) { + const typeError = validateVariableType(variableName, value, variableType); + if (typeError) { + errors.push(typeError); + } + } + } + + return errors; +} + +// 변수 타입 검증 (기존과 동일) +function validateVariableType( + variableName: string, + value: unknown, + expectedType: string +): TemplateValidationError | null { + switch (expectedType) { + case 'string': + if (typeof value !== 'string') { + return { + field: variableName, + message: `Variable '${variableName}' should be a string, got ${typeof value}` + }; + } + break; + case 'number': + if (typeof value !== 'number' && !Number.isFinite(Number(value))) { + return { + field: variableName, + message: `Variable '${variableName}' should be a number` + }; + } + break; + case 'boolean': + if (typeof value !== 'boolean') { + return { + field: variableName, + message: `Variable '${variableName}' should be a boolean` + }; + } + break; + case 'date': + if (!(value instanceof Date) && isNaN(Date.parse(String(value)))) { + return { + field: variableName, + message: `Variable '${variableName}' should be a valid date` + }; + } + break; + } + return null; +} + +// 기존 파일 기반 템플릿 로더 (호환성 유지) +async function loadFileBasedTemplate( + templateName: string, + data: Record<string, unknown>, + fallbackSubject?: string +): Promise<TemplateRenderResult> { + const templatePath = path.join(process.cwd(), 'lib', 'mail', 'templates', `${templateName}.hbs`); + + if (!fs.existsSync(templatePath)) { + throw new Error(`Template not found: ${templatePath}`); + } + + const source = fs.readFileSync(templatePath, 'utf8'); + const compiledTemplate = handlebars.compile(source); + const html = compiledTemplate(data); + + // 파일 기반에서는 fallback subject 사용 + const subject = fallbackSubject || `Email from ${process.env.Email_From_Name || 'System'}`; + + return { subject, html, validationErrors: [] }; +} + +// 이전 버전과의 호환성을 위한 래퍼 함수 +export async function sendEmailLegacy({ + to, + subject, + template, + context, + cc, + from, + attachments = [] +}: Required<Pick<SendEmailOptions, 'subject'>> & Omit<SendEmailOptions, 'subject'>) { + return await sendEmail({ + to, + subject, + template, + context, + cc, + from, + attachments + }); +} + +// 템플릿 캐시 클리어 (관리자 기능용) +export function clearTemplateCache(templateSlug?: string) { + if (templateSlug) { + templateCache.delete(templateSlug); + } else { + templateCache.clear(); + } +} + +// 템플릿 미리보기 함수 (개발/테스트용) +export async function previewTemplate( + templateSlug: string, + sampleData: Record<string, unknown> +): Promise<TemplateRenderResult> { + return await loadAndRenderTemplate(templateSlug, sampleData); +} + +// Subject만 미리보기하는 함수 +export async function previewSubject( + templateSlug: string, + sampleData: Record<string, unknown> +): Promise<{ subject: string; validationErrors: TemplateValidationError[] }> { + const result = await loadAndRenderTemplate(templateSlug, sampleData); + return { + subject: result.subject, + validationErrors: result.validationErrors + }; }
\ No newline at end of file diff --git a/lib/mail/service.ts b/lib/mail/service.ts deleted file mode 100644 index cbd02953..00000000 --- a/lib/mail/service.ts +++ /dev/null @@ -1,380 +0,0 @@ -'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 |
