diff options
Diffstat (limited to 'lib/mail')
| -rw-r--r-- | lib/mail/layouts/base.hbs | 22 | ||||
| -rw-r--r-- | lib/mail/mailer.ts | 133 | ||||
| -rw-r--r-- | lib/mail/partials/footer.hbs | 8 | ||||
| -rw-r--r-- | lib/mail/partials/header.hbs | 7 | ||||
| -rw-r--r-- | lib/mail/sendEmail.ts | 53 | ||||
| -rw-r--r-- | lib/mail/templates/evaluation-request.hbs | 285 |
6 files changed, 444 insertions, 64 deletions
diff --git a/lib/mail/layouts/base.hbs b/lib/mail/layouts/base.hbs deleted file mode 100644 index 2e18f035..00000000 --- a/lib/mail/layouts/base.hbs +++ /dev/null @@ -1,22 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> - <head> - <meta charset="UTF-8" /> - <title>{{subject}}</title> - </head> - <body style="margin:0; padding:20px; background-color:#f5f5f5; font-family:Arial, sans-serif; color:#111827;"> - <table width="100%" cellpadding="0" cellspacing="0" border="0" align="center"> - <tr> - <td align="center"> - <table width="600" cellpadding="0" cellspacing="0" border="0" style="background-color:#ffffff; border:1px solid #e5e7eb; border-radius:6px; padding:24px;"> - <tr> - <td> - {{{body}}} - </td> - </tr> - </table> - </td> - </tr> - </table> - </body> -</html> diff --git a/lib/mail/mailer.ts b/lib/mail/mailer.ts index 329e2e52..61201e99 100644 --- a/lib/mail/mailer.ts +++ b/lib/mail/mailer.ts @@ -15,18 +15,137 @@ const transporter = nodemailer.createTransport({ }, }); -// 템플릿 로더 함수 - 단순화된 버전 +// 헬퍼 함수들 등록 +function registerHandlebarsHelpers() { + // i18next 헬퍼 등록 + handlebars.registerHelper('t', function(key: string, options: { hash?: Record<string, unknown> }) { + // options.hash에는 Handlebars에서 넘긴 named parameter들이 들어있음 + return i18next.t(key, options.hash || {}); + }); + + // eq 헬퍼 등록 - 두 값을 비교 (블록 헬퍼) + handlebars.registerHelper('eq', function(a: any, b: any, options: any) { + if (a === b) { + return options.fn(this); + } else { + return options.inverse(this); + } + }); + + // 기타 유용한 헬퍼들 + handlebars.registerHelper('ne', function(a: any, b: any, options: any) { + if (a !== b) { + return options.fn(this); + } else { + return options.inverse(this); + } + }); + + handlebars.registerHelper('gt', function(a: any, b: any, options: any) { + if (a > b) { + return options.fn(this); + } else { + return options.inverse(this); + } + }); + + handlebars.registerHelper('gte', function(a: any, b: any, options: any) { + if (a >= b) { + return options.fn(this); + } else { + return options.inverse(this); + } + }); + + handlebars.registerHelper('lt', function(a: any, b: any, options: any) { + if (a < b) { + return options.fn(this); + } else { + return options.inverse(this); + } + }); + + handlebars.registerHelper('lte', function(a: any, b: any, options: any) { + if (a <= b) { + return options.fn(this); + } else { + return options.inverse(this); + } + }); + + // and 헬퍼 - 모든 조건이 true인지 확인 (블록 헬퍼) + handlebars.registerHelper('and', function(...args: any[]) { + // 마지막 인자는 Handlebars 옵션 + const options = args[args.length - 1]; + const values = args.slice(0, -1); + + if (values.every(Boolean)) { + return options.fn(this); + } else { + return options.inverse(this); + } + }); + + // or 헬퍼 - 하나라도 true인지 확인 (블록 헬퍼) + handlebars.registerHelper('or', function(...args: any[]) { + // 마지막 인자는 Handlebars 옵션 + const options = args[args.length - 1]; + const values = args.slice(0, -1); + + if (values.some(Boolean)) { + return options.fn(this); + } else { + return options.inverse(this); + } + }); + + // not 헬퍼 - 값 반전 (블록 헬퍼) + handlebars.registerHelper('not', function(value: any, options: any) { + if (!value) { + return options.fn(this); + } else { + return options.inverse(this); + } + }); + + // formatDate 헬퍼 - 날짜 포맷팅 + handlebars.registerHelper('formatDate', function(date: string | Date, format: string = 'YYYY-MM-DD') { + if (!date) return ''; + const dateObj = new Date(date); + if (isNaN(dateObj.getTime())) return ''; + + // 간단한 날짜 포맷팅 (더 복잡한 경우 moment.js나 date-fns 사용) + const year = dateObj.getFullYear(); + const month = String(dateObj.getMonth() + 1).padStart(2, '0'); + const day = String(dateObj.getDate()).padStart(2, '0'); + + return format + .replace('YYYY', String(year)) + .replace('MM', month) + .replace('DD', day); + }); + + // formatNumber 헬퍼 - 숫자 포맷팅 + handlebars.registerHelper('formatNumber', function(number: number, locale: string = 'ko-KR') { + if (typeof number !== 'number') return number; + return new Intl.NumberFormat(locale).format(number); + }); +} + +// 헬퍼 등록 실행 +registerHandlebarsHelpers(); + +// 템플릿 로더 함수 function loadTemplate(templateName: string, data: Record<string, unknown>) { 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 template = handlebars.compile(source); return template(data); } -// i18next 헬퍼 등록 -handlebars.registerHelper('t', function(key: string, options: { hash?: Record<string, unknown> }) { - // 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/partials/footer.hbs b/lib/mail/partials/footer.hbs deleted file mode 100644 index 06aae57d..00000000 --- a/lib/mail/partials/footer.hbs +++ /dev/null @@ -1,8 +0,0 @@ -<table width="100%" cellpadding="0" cellspacing="0" style="margin-top:32px; border-top:1px solid #e5e7eb; padding-top:16px;"> - <tr> - <td align="center"> - <p style="font-size:16px; color:#6b7280; margin:4px 0;">© {{currentYear}} EVCP. {{t "email.vendor.invitation.copyright"}}</p> - <p style="font-size:16px; color:#6b7280; margin:4px 0;">{{t "email.vendor.invitation.no_reply"}}</p> - </td> - </tr> -</table> diff --git a/lib/mail/partials/header.hbs b/lib/mail/partials/header.hbs deleted file mode 100644 index 7898c82e..00000000 --- a/lib/mail/partials/header.hbs +++ /dev/null @@ -1,7 +0,0 @@ -<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px; border-bottom:1px solid #163CC4; padding-bottom:16px;"> - <tr> - <td align="center"> - <span style="display: block; text-align: left; color: #163CC4; font-weight: bold; font-size: 32px;">eVCP</span> - </td> - </tr> -</table> diff --git a/lib/mail/sendEmail.ts b/lib/mail/sendEmail.ts index 97617e7a..3f88cb04 100644 --- a/lib/mail/sendEmail.ts +++ b/lib/mail/sendEmail.ts @@ -17,29 +17,42 @@ interface SendEmailOptions { }[] } -export async function sendEmail({ - to, - subject, - template, - context, +export async function sendEmail({ + to, + subject, + template, + context, cc, // cc 매개변수 추가 attachments = [] }: SendEmailOptions) { - const { t, i18n } = await useTranslation(context.language ?? "en", "translation"); + try { + // i18n 설정 + const { t, i18n } = await useTranslation(context.language ?? "en", "translation"); + + // t 헬퍼만 동적으로 등록 (이미 mailer.ts에서 기본 등록되어 있지만, 언어별로 다시 등록) + handlebars.registerHelper("t", function (key: string, options: any) { + // 여기서 i18n은 로컬 인스턴스 + return i18n.t(key, options.hash || {}); + }); - handlebars.registerHelper("t", function (key: string, options: any) { - // 여기서 i18n은 로컬 인스턴스 - return i18n.t(key, options.hash || {}); - }); + // 템플릿 컴파일 및 HTML 생성 + const html = loadTemplate(template, context); - const html = loadTemplate(template, context); + // 이메일 발송 + const result = await transporter.sendMail({ + from: `"${process.env.Email_From_Name}" <${process.env.Email_From_Address}>`, + to, + cc, // cc 필드 추가 + subject, + html, + attachments + }); - await transporter.sendMail({ - from: `"${process.env.Email_From_Name}" <${process.env.Email_From_Address}>`, - to, - cc, // cc 필드 추가 - subject, - html, - attachments - }); -} + console.log(`이메일 발송 성공: ${to}`, result.messageId); + return result; + + } catch (error) { + console.error(`이메일 발송 실패: ${to}`, error); + throw error; + } +}
\ No newline at end of file diff --git a/lib/mail/templates/evaluation-request.hbs b/lib/mail/templates/evaluation-request.hbs new file mode 100644 index 00000000..84aae0f5 --- /dev/null +++ b/lib/mail/templates/evaluation-request.hbs @@ -0,0 +1,285 @@ +<!DOCTYPE html> +<html lang="ko"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>협력업체 평가 요청</title> + <style> + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans KR', Arial, sans-serif; + line-height: 1.6; + color: #333; + max-width: 600px; + margin: 0 auto; + padding: 20px; + background-color: #f9f9f9; + } + .container { + background-color: white; + border-radius: 8px; + padding: 30px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + } + .header { + text-align: center; + margin-bottom: 30px; + padding-bottom: 20px; + border-bottom: 2px solid #e5e5e5; + } + .header h1 { + color: #1f2937; + margin: 0; + font-size: 24px; + font-weight: 600; + } + .header .subtitle { + color: #6b7280; + font-size: 14px; + margin-top: 5px; + } + .greeting { + margin-bottom: 25px; + font-size: 16px; + } + .evaluation-info { + background-color: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 6px; + padding: 20px; + margin: 20px 0; + } + .evaluation-info h3 { + color: #1e40af; + margin: 0 0 15px 0; + font-size: 18px; + } + .info-grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 8px 15px; + margin-bottom: 15px; + } + .info-label { + font-weight: 600; + color: #374151; + } + .info-value { + color: #6b7280; + } + .status-badge { + display: inline-block; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + } + .status-domestic { background-color: #dcfce7; color: #166534; } + .status-foreign { background-color: #dbeafe; color: #1d4ed8; } + .status-equipment { background-color: #fef3c7; color: #92400e; } + .status-bulk { background-color: #e0e7ff; color: #3730a3; } + .department-badge { + background-color: #1f2937; + color: white; + padding: 6px 12px; + border-radius: 4px; + font-size: 14px; + font-weight: 500; + display: inline-block; + margin-bottom: 15px; + } + .reviewers-section { + margin: 25px 0; + } + .reviewers-section h4 { + color: #374151; + margin-bottom: 15px; + font-size: 16px; + } + .reviewer-list { + background-color: #f1f5f9; + border-radius: 6px; + padding: 15px; + } + .reviewer-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid #e2e8f0; + } + .reviewer-item:last-child { + border-bottom: none; + } + .reviewer-name { + font-weight: 500; + color: #1f2937; + } + .reviewer-dept { + font-size: 12px; + color: #6b7280; + background-color: #e5e7eb; + padding: 2px 6px; + border-radius: 3px; + } + .message-section { + background-color: #fffbeb; + border-left: 4px solid #f59e0b; + padding: 15px; + margin: 20px 0; + } + .message-section h4 { + color: #92400e; + margin: 0 0 10px 0; + font-size: 14px; + } + .message-text { + color: #78350f; + font-style: italic; + } + .action-button { + display: inline-block; + background-color: #1f2937; + color: white; + padding: 12px 24px; + text-decoration: none; + border-radius: 6px; + font-weight: 500; + text-align: center; + margin: 25px 0; + } + .action-button:hover { + background-color: #374151; + } + .footer { + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid #e5e5e5; + text-align: center; + color: #6b7280; + font-size: 14px; + } + .deadline-notice { + background-color: #fef2f2; + border: 1px solid #fecaca; + border-radius: 6px; + padding: 15px; + margin: 20px 0; + } + .deadline-notice .deadline-label { + color: #dc2626; + font-weight: 600; + font-size: 14px; + } + .deadline-notice .deadline-date { + color: #991b1b; + font-size: 16px; + font-weight: 500; + } + </style> +</head> +<body> + <div class="container"> + <div class="header"> + <h1>🏢 협력업체 정기평가 요청</h1> + <div class="subtitle">Vendor Performance Evaluation Request</div> + </div> + + <div class="greeting"> + 안녕하세요, <strong>{{reviewerName}}</strong>님 + </div> + + <p> + {{departmentLabel}}으로 지정되어 아래 협력업체에 대한 정기평가를 요청드립니다. + </p> + + <div class="department-badge"> + 📋 {{departmentLabel}} + </div> + + <!-- 평가 대상 정보 --> + <div class="evaluation-info"> + <h3>📊 평가 대상 정보</h3> + + <div class="info-grid"> + <span class="info-label">업체명:</span> + <span class="info-value"><strong>{{evaluation.vendorName}}</strong></span> + + <span class="info-label">업체코드:</span> + <span class="info-value">{{evaluation.vendorCode}}</span> + + <span class="info-label">평가년도:</span> + <span class="info-value">{{evaluation.evaluationYear}}년</span> + + + <span class="info-label">구분:</span> + <span class="info-value"> + {{#eq evaluation.division "SHIP"}}조선{{else}}{{#eq evaluation.division "PLANT"}}해양{{/eq}}{{/eq}} + </span> + + <span class="info-label">내외자:</span> + <span class="info-value"> + <span class="status-badge {{#eq evaluation.domesticForeign 'DOMESTIC'}}status-domestic{{else}}status-foreign{{/eq}}"> + {{#eq evaluation.domesticForeign "DOMESTIC"}}국내{{else}}해외{{/eq}} + </span> + </span> + + <span class="info-label">자재구분:</span> + <span class="info-value"> + <span class="status-badge {{#eq evaluation.materialType 'EQUIPMENT'}}status-equipment{{else}}{{#eq evaluation.materialType 'BULK'}}status-bulk{{else}}status-equipment{{/eq}}{{/eq}}"> + {{#eq evaluation.materialType "EQUIPMENT"}}기자재{{else}}{{#eq evaluation.materialType "BULK"}}벌크{{else}}{{#eq evaluation.materialType "EQUIPMENT_BULK"}}기자재+벌크{{else}}{{evaluation.materialType}}{{/eq}}{{/eq}}{{/eq}} + </span> + </span> + </div> + + </div> + + <!-- 함께 평가하는 다른 담당자들 --> + {{#if otherReviewers}} + <div class="reviewers-section"> + <h4>👥 함께 평가하는 다른 담당자</h4> + <div class="reviewer-list"> + {{#each otherReviewers}} + <div class="reviewer-item"> + <div> + <div class="reviewer-name">{{this.name}}</div> + <div style="font-size: 12px; color: #6b7280;">{{this.email}}</div> + </div> + <div class="reviewer-dept">{{this.department}}</div> + </div> + {{/each}} + </div> + </div> + {{/if}} + + <!-- 요청 메시지 --> + {{#if message}} + <div class="message-section"> + <h4>💬 요청 메시지</h4> + <div class="message-text">"{{message}}"</div> + </div> + {{/if}} + + <!-- 평가 시작 버튼 --> + <div style="text-align: center;"> + <a href="{{evaluationUrl}}" class="action-button"> + 🚀 평가 시작하기 + </a> + </div> + + <div style="margin-top: 25px; padding: 15px; background-color: #f8fafc; border-radius: 6px;"> + <p style="margin: 0; font-size: 14px; color: #6b7280;"> + <strong>📋 평가 진행 안내:</strong><br> + • 위 버튼을 클릭하여 온라인 평가 시스템에 접속하실 수 있습니다<br> + • 평가 기준에 따라 각 항목별로 점수를 입력해 주세요<br> + • 모든 평가가 완료되면 자동으로 최종 집계됩니다<br> + • 문의사항이 있으시면 시스템 관리자에게 연락해 주세요 + </p> + </div> + + <div class="footer"> + <p>본 메일은 협력업체 평가 시스템에서 자동 발송된 메일입니다.</p> + <p style="margin: 5px 0 0 0;">Samsung Heavy Industries Vendor Evaluation System</p> + </div> + </div> +</body> +</html>
\ No newline at end of file |
