From 3bf1952c1dad9d479bb8b22031b06a7434d37c37 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Thu, 26 Jun 2025 02:58:20 +0000 Subject: (최겸) 공통 > 이메일 템플릿 관리 개발 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/mail/mailer.ts | 48 +-- lib/mail/service.ts | 380 ++++++++++++++++++++ lib/mail/templates/admin-created.hbs | 91 +++-- lib/mail/templates/admin-email-changed.hbs | 101 ++++-- lib/mail/templates/cbe-invitation.hbs | 159 ++++++--- lib/mail/templates/contract-sign-request.hbs | 143 +++++--- lib/mail/templates/evaluation-review-request.hbs | 322 ++++++++--------- lib/mail/templates/initial-rfq-invitation.hbs | 328 +++++++++--------- lib/mail/templates/investigation-request.hbs | 103 ++++-- lib/mail/templates/otp.hbs | 45 ++- lib/mail/templates/password-reset.hbs | 269 +++++++++++++++ lib/mail/templates/po-rfq-notification-en.hbs | 45 ++- lib/mail/templates/po-rfq-notification-ko.hbs | 45 ++- lib/mail/templates/pq-submitted-admin.hbs | 103 ++++-- lib/mail/templates/pq-submitted-vendor.hbs | 111 ++++-- lib/mail/templates/pq.hbs | 139 +++++--- lib/mail/templates/project-pq.hbs | 157 +++++---- lib/mail/templates/rfq-invite.hbs | 127 ++++--- lib/mail/templates/rfq-notification.hbs | 330 +++++++++--------- .../templates/tech-sales-quotation-accepted-ko.hbs | 265 ++++++++------ .../templates/tech-sales-quotation-rejected-ko.hbs | 275 ++++++++------- .../tech-sales-quotation-submitted-manager-ko.hbs | 295 +++++++++------- .../tech-sales-quotation-submitted-vendor-ko.hbs | 289 +++++++++------- lib/mail/templates/tech-sales-rfq-invite-ko.hbs | 381 ++++++++++++--------- lib/mail/templates/vendor-active.hbs | 91 +++-- lib/mail/templates/vendor-additional-info.hbs | 78 ++++- lib/mail/templates/vendor-invitation.hbs | 117 +++++-- lib/mail/templates/vendor-pq-comment.hbs | 123 ++++--- lib/mail/templates/vendor-pq-status.hbs | 87 +++-- lib/mail/templates/vendor-project-pq-status.hbs | 125 ++++--- lib/mail/templates/vendor-user-created.hbs | 66 ++++ 31 files changed, 3457 insertions(+), 1781 deletions(-) create mode 100644 lib/mail/service.ts create mode 100644 lib/mail/templates/password-reset.hbs create mode 100644 lib/mail/templates/vendor-user-created.hbs (limited to 'lib/mail') diff --git a/lib/mail/mailer.ts b/lib/mail/mailer.ts index 3474a373..329e2e52 100644 --- a/lib/mail/mailer.ts +++ b/lib/mail/mailer.ts @@ -15,48 +15,18 @@ const transporter = nodemailer.createTransport({ }, }); -// // Handlebars 템플릿 로더 함수 -// function loadTemplate(templateName: string, data: Record) { -// const templatePath = path.join(process.cwd(), 'lib', 'mail', 'templates', `${templateName}.hbs`); -// const source = fs.readFileSync(templatePath, 'utf8'); -// const template = handlebars.compile(source); -// return template(data); -// } -function applyLayout(layoutName: string, content: string, context: Record) { - const layoutPath = path.join(process.cwd(), 'lib', 'mail', 'layouts', `${layoutName}.hbs`); - const layoutSource = fs.readFileSync(layoutPath, 'utf8'); - const layoutTemplate = handlebars.compile(layoutSource); - return layoutTemplate({ ...context, body: content }); -} - -// Partials 자동 등록 -function registerPartials() { - const partialsDir = path.join(process.cwd(), 'lib', 'mail', 'partials'); - const filenames = fs.readdirSync(partialsDir); - - filenames.forEach((filename) => { - const name = path.parse(filename).name; - const filepath = path.join(partialsDir, filename); - const source = fs.readFileSync(filepath, 'utf8'); - handlebars.registerPartial(name, source); // {{> header }}, {{> footer }} - }); -} - - -// 템플릿 불러오기 + layout/partials 적용 -function loadTemplate(templateName: string, context: Record, layout = 'base') { - registerPartials(); - +// 템플릿 로더 함수 - 단순화된 버전 +function loadTemplate(templateName: string, data: Record) { const templatePath = path.join(process.cwd(), 'lib', 'mail', 'templates', `${templateName}.hbs`); const source = fs.readFileSync(templatePath, 'utf8'); const template = handlebars.compile(source); - - const content = template(context); // 본문 먼저 처리 - return applyLayout(layout, content, context); // base.hbs로 감싸기 + return template(data); } -handlebars.registerHelper('t', function(key: string, options: any) { - // options.hash에는 Handlebars에서 넘긴 named parameter들(location=location 등)이 들어있음 - return i18next.t(key, options.hash || {}); - }); + +// i18next 헬퍼 등록 +handlebars.registerHelper('t', function(key: string, options: { hash?: Record }) { + // options.hash에는 Handlebars에서 넘긴 named parameter들이 들어있음 + return i18next.t(key, options.hash || {}); +}); export { transporter, loadTemplate }; \ No newline at end of file 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 { + 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 { + if (initialized) return; + await initializeAsync(); +} + +function registerHandlebarsHelpers(): void { + // i18n 번역 헬퍼 + handlebars.registerHelper('t', function(key: string, options: { hash?: Record }) { + 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('