import { useTranslation } from '@/i18n'; 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; // ํ…œํ”Œ๋ฆฟ slug context: Record; cc?: string | string[]; from?: string; attachments?: { 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(); const CACHE_DURATION = 5 * 60 * 1000; // 5๋ถ„ export async function sendEmail({ to, subject, // ์ด์ œ ์„ ํƒ์  ๋งค๊ฐœ๋ณ€์ˆ˜ template, context, cc, from, attachments = [] }: SendEmailOptions) { try { // i18n ์„ค์ • const { t, i18n } = await useTranslation(context.language ?? "en", "translation"); // Handlebars ํ—ฌํผ ๋“ฑ๋ก handlebars.unregisterHelper('t'); handlebars.registerHelper("t", function (key: string, options: any) { return i18n.t(key, options?.hash || {}); }); // ํ…œํ”Œ๋ฆฟ ๋ฐ์ดํ„ฐ ์ค€๋น„ const templateData = { ...context, t: (key: string, options?: any) => i18n.t(key, options || {}), i18n: i18n }; // ํ…œํ”Œ๋ฆฟ์—์„œ subject์™€ content ๋ชจ๋‘ ๋กœ๋“œ const { subject: renderedSubject, html, validationErrors } = await loadAndRenderTemplate(template, templateData, subject); if (validationErrors.length > 0) { console.warn(`ํ…œํ”Œ๋ฆฟ ๊ฒ€์ฆ ๊ฒฝ๊ณ  (${template}):`, validationErrors); } // from ์ฃผ์†Œ ์„ค์ • const fromAddress = from || `"${process.env.Email_From_Name}" <${process.env.Email_From_Address}>`; // ์ด๋ฉ”์ผ ๋ฐœ์†ก const result = await transporter.sendMail({ from: fromAddress, to, cc, subject: renderedSubject, // ํ…œํ”Œ๋ฆฟ์—์„œ ๋ Œ๋”๋ง๋œ subject ์‚ฌ์šฉ html, attachments }); 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, fallbackSubject?: string ): Promise { 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 ): 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, fallbackSubject?: string ): Promise { 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> & Omit) { 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 ): Promise { return await loadAndRenderTemplate(templateSlug, sampleData); } // Subject๋งŒ ๋ฏธ๋ฆฌ๋ณด๊ธฐํ•˜๋Š” ํ•จ์ˆ˜ export async function previewSubject( templateSlug: string, sampleData: Record ): Promise<{ subject: string; validationErrors: TemplateValidationError[] }> { const result = await loadAndRenderTemplate(templateSlug, sampleData); return { subject: result.subject, validationErrors: result.validationErrors }; }