summaryrefslogtreecommitdiff
path: root/lib/mail/sendEmail.ts
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-21 07:19:52 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-21 07:19:52 +0000
commit9da494b0e3bbe7b513521d0915510fe9ee376b8b (patch)
treef936f69626bf2808ac409ce7cad97433465b3672 /lib/mail/sendEmail.ts
parente275618ff8a1ce6977d3e2567d943edb941897f9 (diff)
(대표님, 최겸) 작업사항 - 이메일 템플릿, 메일링, 기술영업 요구사항 반영
Diffstat (limited to 'lib/mail/sendEmail.ts')
-rw-r--r--lib/mail/sendEmail.ts302
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