diff options
Diffstat (limited to 'lib/mail/sendEmail.ts')
| -rw-r--r-- | lib/mail/sendEmail.ts | 302 |
1 files changed, 275 insertions, 27 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 |
