summaryrefslogtreecommitdiff
path: root/lib/mail
diff options
context:
space:
mode:
Diffstat (limited to 'lib/mail')
-rw-r--r--lib/mail/sendEmail.ts302
-rw-r--r--lib/mail/service.ts380
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