From 9da494b0e3bbe7b513521d0915510fe9ee376b8b Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 21 Jul 2025 07:19:52 +0000 Subject: (대표님, 최겸) 작업사항 - 이메일 템플릿, 메일링, 기술영업 요구사항 반영 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 +- .../evcp/(evcp)/email-template/[name]/page.tsx | 26 - .../evcp/(evcp)/email-template/[slug]/page.tsx | 44 + app/[lng]/evcp/(evcp)/email-template/page.tsx | 89 +- components/mail/mail-template-editor-client.tsx | 205 +- components/template-editor/html-code-editor.tsx | 194 + db/migrations/0214_mature_norman_osborn.sql | 30 + db/migrations/0215_secret_old_lace.sql | 42 + db/migrations/0216_petite_la_nuit.sql | 58 + db/migrations/0217_old_ezekiel.sql | 64 + db/migrations/0218_lively_millenium_guard.sql | 78 + db/migrations/0219_dashing_maggott.sql | 83 + db/migrations/meta/0214_snapshot.json | 39175 ++++++++++++++++++ db/migrations/meta/0215_snapshot.json | 39443 ++++++++++++++++++ db/migrations/meta/0216_snapshot.json | 39611 ++++++++++++++++++ db/migrations/meta/0217_snapshot.json | 39638 ++++++++++++++++++ db/migrations/meta/0218_snapshot.json | 39662 ++++++++++++++++++ db/migrations/meta/0219_snapshot.json | 39686 +++++++++++++++++++ db/migrations/meta/_journal.json | 42 + db/schema/techSales.ts | 59 +- db/schema/techVendors.ts | 4 +- db/schema/templates.ts | 244 + .../editor/template-content-editor.tsx | 609 + lib/email-template/editor/template-editor.tsx | 175 + lib/email-template/editor/template-preview.tsx | 406 + lib/email-template/editor/template-settings.tsx | 474 + .../editor/template-variable-manager.tsx | 562 + lib/email-template/security.ts | 178 + lib/email-template/service.ts | 899 + lib/email-template/table/create-template-sheet.tsx | 381 + .../table/delete-template-dialog.tsx | 137 + .../table/duplicate-template-sheet.tsx | 208 + lib/email-template/table/email-template-table.tsx | 155 + .../table/template-table-columns.tsx | 296 + .../table/template-table-toolbar-actions.tsx | 115 + lib/email-template/table/update-template-sheet.tsx | 215 + lib/email-template/validations.ts | 82 + lib/file-download-log/service.ts | 2 +- lib/mail/sendEmail.ts | 302 +- lib/mail/service.ts | 380 - lib/qna/table/qna-detail.tsx | 4 +- lib/sedp/get-form-tags.ts | 8 +- lib/utils.ts | 82 +- package-lock.json | 307 +- package.json | 9 + types/table.d.ts | 2 +- 46 files changed, 243962 insertions(+), 506 deletions(-) delete mode 100644 app/[lng]/evcp/(evcp)/email-template/[name]/page.tsx create mode 100644 app/[lng]/evcp/(evcp)/email-template/[slug]/page.tsx create mode 100644 components/template-editor/html-code-editor.tsx create mode 100644 db/migrations/0214_mature_norman_osborn.sql create mode 100644 db/migrations/0215_secret_old_lace.sql create mode 100644 db/migrations/0216_petite_la_nuit.sql create mode 100644 db/migrations/0217_old_ezekiel.sql create mode 100644 db/migrations/0218_lively_millenium_guard.sql create mode 100644 db/migrations/0219_dashing_maggott.sql create mode 100644 db/migrations/meta/0214_snapshot.json create mode 100644 db/migrations/meta/0215_snapshot.json create mode 100644 db/migrations/meta/0216_snapshot.json create mode 100644 db/migrations/meta/0217_snapshot.json create mode 100644 db/migrations/meta/0218_snapshot.json create mode 100644 db/migrations/meta/0219_snapshot.json create mode 100644 db/schema/templates.ts create mode 100644 lib/email-template/editor/template-content-editor.tsx create mode 100644 lib/email-template/editor/template-editor.tsx create mode 100644 lib/email-template/editor/template-preview.tsx create mode 100644 lib/email-template/editor/template-settings.tsx create mode 100644 lib/email-template/editor/template-variable-manager.tsx create mode 100644 lib/email-template/security.ts create mode 100644 lib/email-template/service.ts create mode 100644 lib/email-template/table/create-template-sheet.tsx create mode 100644 lib/email-template/table/delete-template-dialog.tsx create mode 100644 lib/email-template/table/duplicate-template-sheet.tsx create mode 100644 lib/email-template/table/email-template-table.tsx create mode 100644 lib/email-template/table/template-table-columns.tsx create mode 100644 lib/email-template/table/template-table-toolbar-actions.tsx create mode 100644 lib/email-template/table/update-template-sheet.tsx create mode 100644 lib/email-template/validations.ts delete mode 100644 lib/mail/service.ts diff --git a/README.md b/README.md index f0592393..230e3440 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ tar -czhvf archive_$(date +%Y%m%d_%H%M%S).tar.gz --exclude="node_modules" --exclude="public" --exclude=".next" --exclude=".git" --exclude="tmp" --exclude="temp" . -zip -r archive-20250710.zip . -x "./public/*" "./.git/*" "./.next/*" "./tmp/*" "./temp/*" "./.cursor/*" +zip -r archive-$(date +%Y%m%d_%H%M%S).zip . -x "./public/*" "./.git/*" "./.next/*" "./tmp/*" "./temp/*" "./.cursor/*" +zip -r archive-$(date +%Y%m%d_%H%M%S).zip . -x "./public/*" "./.git/*" "./.next/*" "./tmp/*" "./temp/*" "./.cursor/*" "./node_modules/*" ``` 2. 전체 압축 diff --git a/app/[lng]/evcp/(evcp)/email-template/[name]/page.tsx b/app/[lng]/evcp/(evcp)/email-template/[name]/page.tsx deleted file mode 100644 index cccc10fc..00000000 --- a/app/[lng]/evcp/(evcp)/email-template/[name]/page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { getTemplateAction } from '@/lib/mail/service'; -import MailTemplateEditorClient from '@/components/mail/mail-template-editor-client'; - -interface EditMailTemplatePageProps { - params: { - name: string; - lng: string; - }; -} - -export default async function EditMailTemplatePage({ params }: EditMailTemplatePageProps) { - const { name: templateName } = await params; - - // 서버에서 초기 템플릿 데이터 가져오기 - const result = await getTemplateAction(templateName); - const initialTemplate = result.success ? result.data : null; - - return ( -
- -
- ); -} diff --git a/app/[lng]/evcp/(evcp)/email-template/[slug]/page.tsx b/app/[lng]/evcp/(evcp)/email-template/[slug]/page.tsx new file mode 100644 index 00000000..713c2b6d --- /dev/null +++ b/app/[lng]/evcp/(evcp)/email-template/[slug]/page.tsx @@ -0,0 +1,44 @@ +import * as React from "react" +import { type Metadata } from "next" +import { notFound } from "next/navigation" + +import { getTemplateAction } from "@/lib/email-template/service" +import { TemplateEditor } from "@/lib/email-template/editor/template-editor" + +interface TemplateDetailPageProps { + params: { + slug: string + } +} + +export async function generateMetadata({ params }: TemplateDetailPageProps): Promise { + const result = await getTemplateAction(params.slug) + + if (!result.success || !result.data) { + return { + title: "템플릿을 찾을 수 없음", + } + } + + return { + title: `${result.data.name} - 템플릿 편집`, + description: result.data.description || `${result.data.name} 템플릿을 편집합니다.`, + } +} + +export default async function TemplateDetailPage({ params }: TemplateDetailPageProps) { + const result = await getTemplateAction(params.slug) + + if (!result.success || !result.data) { + notFound() + } + + return ( +
+ +
+ ) +} \ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/email-template/page.tsx b/app/[lng]/evcp/(evcp)/email-template/page.tsx index d66b1588..7f4de341 100644 --- a/app/[lng]/evcp/(evcp)/email-template/page.tsx +++ b/app/[lng]/evcp/(evcp)/email-template/page.tsx @@ -1,22 +1,77 @@ -import { getTemplatesAction } from '@/lib/mail/service'; -import MailTemplatesClient from '@/components/mail/mail-templates-client'; -import { InformationButton } from '@/components/information/information-button'; -export default async function MailTemplatesPage() { - // 서버에서 초기 데이터 가져오기 - const result = await getTemplatesAction(); - const initialData = result.success ? result.data : []; +import * as React from "react" +import { type Metadata } from "next" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { getTemplateList } from "@/lib/email-template/service" +import { type SearchParams } from "@/types/table" +import { SearchParamsEmailTemplateCache } from "@/lib/email-template/validations" +import { TemplateTable } from "@/lib/email-template/table/email-template-table" +import { Shell } from "@/components/shell" +import { getValidFilters } from "@/lib/data-table" +import { Skeleton } from "@/components/ui/skeleton" +import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton" + +export const metadata: Metadata = { + title: "템플릿 관리", + description: "이메일 템플릿을 관리하고 편집합니다.", +} + +interface TemplatePageProps { + searchParams: SearchParams +} + +export default async function TemplatePage(props: TemplatePageProps) { + + const searchParams = await props.searchParams + + const search = SearchParamsEmailTemplateCache.parse(searchParams) + const validFilters = getValidFilters(search.filters) + + + const promises = Promise.all([ + getTemplateList({ + ...search, + filters: validFilters, + }), + + ]) return ( -
-
-
-

메일 템플릿 관리

- + + +
+
+
+
+

+ 이메일 템플릿 관리 +

+ {/* */} +
+
- {/*

이메일 템플릿을 관리할 수 있습니다.

*/}
- -
- ); -} + }> + + + } + > + + + + ) +} \ No newline at end of file diff --git a/components/mail/mail-template-editor-client.tsx b/components/mail/mail-template-editor-client.tsx index dfbeb4e0..c9770aec 100644 --- a/components/mail/mail-template-editor-client.tsx +++ b/components/mail/mail-template-editor-client.tsx @@ -5,7 +5,7 @@ import { useRouter, useParams } from 'next/navigation'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; -import { Save, Eye } from 'lucide-react'; +import { Save, Eye, AlertTriangle } from 'lucide-react'; import { toast } from 'sonner'; import Link from 'next/link'; import { getTemplateAction, updateTemplateAction, previewTemplateAction, TemplateFile } from '@/lib/mail/service'; @@ -17,13 +17,81 @@ interface MailTemplateEditorClientProps { initialTemplate?: Template | null; } -export default function MailTemplateEditorClient({ - templateName, - initialTemplate +// 보안: 허용된 Handlebars 헬퍼와 변수만 정의 +const ALLOWED_VARIABLES = [ + 'userName', 'companyName', 'email', 'date', 'projectName', + 'message', 'currentYear', 'language', 'name', 'loginUrl' +]; + +const ALLOWED_HELPERS = ['if', 'unless', 'each', 'with']; + +// 보안: 위험한 패턴 탐지 +const DANGEROUS_PATTERNS = [ + /\{\{\s*(constructor|prototype|__proto__|process|global|require|import|eval|Function)\s*\}\}/gi, + /\{\{\s*.*\.(constructor|prototype|__proto__)\s*.*\}\}/gi, + /\{\{\s*.*\[\s*['"`]constructor['"`]\s*\]\s*.*\}\}/gi, + /\{\{\s*.*require\s*\(.*\)\s*.*\}\}/gi, + /\{\{\s*.*process\s*\..*\}\}/gi, + /\{\{\s*.*global\s*\..*\}\}/gi, + /\{\{\s*.*this\s*\..*\}\}/gi, + /\{\{\s*#with\s+.*\.\.\s*\}\}/gi, // path traversal +]; + +// 보안: 템플릿 내용 검증 +const validateTemplateContent = (content: string): { isValid: boolean; errors: string[] } => { + const errors: string[] = []; + + // 위험한 패턴 검사 + for (const pattern of DANGEROUS_PATTERNS) { + if (pattern.test(content)) { + errors.push('보안상 위험한 구문이 감지되었습니다.'); + break; + } + } + + // 허용되지 않은 변수 검사 + const variableMatches = content.match(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g); + if (variableMatches) { + for (const match of variableMatches) { + const variable = match.replace(/\{\{\s*|\s*\}\}/g, ''); + if (!ALLOWED_VARIABLES.includes(variable)) { + errors.push(`허용되지 않은 변수 '${variable}'가 사용되었습니다.`); + } + } + } + + // 허용되지 않은 헬퍼 검사 + const helperMatches = content.match(/\{\{\s*#([a-zA-Z_][a-zA-Z0-9_]*)/g); + if (helperMatches) { + for (const match of helperMatches) { + const helper = match.replace(/\{\{\s*#/, ''); + if (!ALLOWED_HELPERS.includes(helper)) { + errors.push(`허용되지 않은 헬퍼 '${helper}'가 사용되었습니다.`); + } + } + } + + return { + isValid: errors.length === 0, + errors + }; +}; + +// 보안: HTML 출력 무력화 ({{{html}}} 형태 방지) +const sanitizeTripleBraces = (content: string): string => { + return content.replace(/\{\{\{([^}]+)\}\}\}/g, (match, variable) => { + // HTML 출력을 일반 변수 출력으로 변환 + return `{{${variable.trim()}}}`; + }); +}; + +export default function MailTemplateEditorClient({ + templateName, + initialTemplate }: MailTemplateEditorClientProps) { const router = useRouter(); const params = useParams(); - + const lng = (params?.lng as string) || 'ko'; const [template, setTemplate] = useState