"use client"; import React, { useState, useEffect, KeyboardEvent } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Send, Mail, FileText, Eye, X, Loader2, CheckCircle2, AlertCircle, Users, UserPlus, EyeOff, Upload, Paperclip, File, Settings, ChevronRight, } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; import { Separator } from "@/components/ui/separator"; import { MailAccount, MailTemplate, getMailAccounts, getMailTemplates, sendMail, extractTemplateVariables, renderTemplateToHtml, saveDraft, updateDraft, } from "@/lib/api/mail"; import { API_BASE_URL } from "@/lib/api/client"; import { useToast } from "@/hooks/use-toast"; export default function MailSendPage() { const router = useRouter(); const searchParams = useSearchParams(); const { toast } = useToast(); const [accounts, setAccounts] = useState([]); const [templates, setTemplates] = useState([]); const [loading, setLoading] = useState(false); const [sending, setSending] = useState(false); // 폼 상태 const [selectedAccountId, setSelectedAccountId] = useState(""); const [selectedTemplateId, setSelectedTemplateId] = useState(""); const [to, setTo] = useState([]); const [cc, setCc] = useState([]); const [bcc, setBcc] = useState([]); const [toInput, setToInput] = useState(""); const [ccInput, setCcInput] = useState(""); const [bccInput, setBccInput] = useState(""); const [subject, setSubject] = useState(""); const [customHtml, setCustomHtml] = useState(""); const [variables, setVariables] = useState>({}); const [showPreview, setShowPreview] = useState(false); const [isEditingHtml, setIsEditingHtml] = useState(false); // HTML 편집 모드 // 템플릿 변수 const [templateVariables, setTemplateVariables] = useState([]); // 첨부파일 const [attachments, setAttachments] = useState([]); const [isDragging, setIsDragging] = useState(false); // 임시 저장 const [draftId, setDraftId] = useState(null); const [lastSaved, setLastSaved] = useState(null); const [autoSaving, setAutoSaving] = useState(false); useEffect(() => { loadData(); // 답장/전달 데이터 처리 const action = searchParams.get("action"); const dataParam = searchParams.get("data"); if (action && dataParam) { try { const data = JSON.parse(decodeURIComponent(dataParam)); if (action === "reply") { // 답장: 받는사람 자동 입력, 제목에 Re: 추가 const fromEmail = data.originalFrom.match(/<(.+?)>/)?.[1] || data.originalFrom; setTo([fromEmail]); setSubject(data.originalSubject.startsWith("Re: ") ? data.originalSubject : `Re: ${data.originalSubject}` ); // 원본 메일을 순수 텍스트로 추가 (사용자가 읽기 쉽게) const originalMessage = ` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 원본 메일: 보낸사람: ${data.originalFrom} 날짜: ${new Date(data.originalDate).toLocaleString("ko-KR")} 제목: ${data.originalSubject} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ${data.originalBody}`; setCustomHtml(originalMessage); toast({ title: '답장 작성', description: '받는사람과 제목이 자동으로 입력되었습니다.', }); } else if (action === "forward") { // 전달: 받는사람 비어있음, 제목에 Fwd: 추가 setSubject(data.originalSubject.startsWith("Fwd: ") ? data.originalSubject : `Fwd: ${data.originalSubject}` ); // 원본 메일을 순수 텍스트로 추가 (사용자가 읽기 쉽게) const originalMessage = ` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 전달된 메일: 보낸사람: ${data.originalFrom} 날짜: ${new Date(data.originalDate).toLocaleString("ko-KR")} 제목: ${data.originalSubject} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ${data.originalBody}`; setCustomHtml(originalMessage); toast({ title: '메일 전달', description: '전달할 메일 내용이 입력되었습니다. 받는사람을 입력하세요.', }); } // URL에서 파라미터 제거 (깔끔하게) router.replace("/admin/mail/send"); } catch (error) { // console.error("답장/전달 데이터 파싱 실패:", error); } return; } // 임시 저장 메일 불러오기 const draftIdParam = searchParams.get("draftId"); const toParam = searchParams.get("to"); const ccParam = searchParams.get("cc"); const bccParam = searchParams.get("bcc"); const subjectParam = searchParams.get("subject"); const contentParam = searchParams.get("content"); const accountIdParam = searchParams.get("accountId"); if (draftIdParam) { setDraftId(draftIdParam); if (toParam) setTo(toParam.split(",").filter(Boolean)); if (ccParam) setCc(ccParam.split(",").filter(Boolean)); if (bccParam) setBcc(bccParam.split(",").filter(Boolean)); if (subjectParam) setSubject(subjectParam); if (contentParam) setCustomHtml(contentParam); if (accountIdParam) setSelectedAccountId(accountIdParam); toast({ title: '임시 저장 메일 불러오기', description: '작성 중이던 메일을 불러왔습니다.', }); return; } }, [searchParams]); const loadData = async () => { try { setLoading(true); const [accountsData, templatesData] = await Promise.all([ getMailAccounts(), getMailTemplates(), ]); const activeAccounts = accountsData.filter((acc) => acc.status === "active"); setAccounts(activeAccounts); setTemplates(templatesData); // 계정이 선택되지 않았고, 활성 계정이 있으면 첫 번째 계정 자동 선택 if (!selectedAccountId && activeAccounts.length > 0) { setSelectedAccountId(activeAccounts[0].id); // console.log('🔧 첫 번째 계정 자동 선택:', activeAccounts[0].email); } // console.log('📦 데이터 로드 완료:', { // accounts: accountsData.length, // templates: templatesData.length, // templatesDetail: templatesData.map(t => ({ // id: t.id, // name: t.name, // componentsCount: t.components?.length || 0 // })) // }); } catch (error: unknown) { const err = error as Error; // console.error('❌ 데이터 로드 실패:', err); toast({ title: "데이터 로드 실패", description: err.message, variant: "destructive", }); } finally { setLoading(false); } }; // 임시 저장 함수 const handleAutoSave = async () => { if (!selectedAccountId || (!subject && !customHtml && to.length === 0)) { return; // 저장할 내용이 없으면 스킵 } try { setAutoSaving(true); const draftData = { accountId: selectedAccountId, accountName: accounts.find(a => a.id === selectedAccountId)?.name || "", accountEmail: accounts.find(a => a.id === selectedAccountId)?.email || "", to, cc, bcc, subject, htmlContent: customHtml, templateId: selectedTemplateId || undefined, }; if (draftId) { // 기존 임시 저장 업데이트 await updateDraft(draftId, draftData); } else { // 새로운 임시 저장 const savedDraft = await saveDraft(draftData); if (savedDraft && savedDraft.id) { setDraftId(savedDraft.id); } } setLastSaved(new Date()); } catch (error) { // console.error('임시 저장 실패:', error); } finally { setAutoSaving(false); } }; // 30초마다 자동 저장 useEffect(() => { const interval = setInterval(() => { handleAutoSave(); }, 30000); // 30초 return () => clearInterval(interval); }, [selectedAccountId, to, cc, bcc, subject, customHtml, selectedTemplateId, draftId]); // 템플릿 선택 시 (원본 다시 로드) const handleTemplateChange = async (templateId: string) => { console.log('🔄 템플릿 선택됨:', templateId); // "__custom__"는 직접 작성을 의미 if (templateId === "__custom__") { // console.log('✏️ 직접 작성 모드'); setSelectedTemplateId(""); setTemplateVariables([]); setVariables({}); return; } try { // 🎯 원본 템플릿을 API에서 다시 로드 (수정사항 초기화) // console.log('🔃 원본 템플릿 API에서 재로드 중...'); const freshTemplates = await getMailTemplates(); const template = freshTemplates.find((t) => t.id === templateId); // console.log('📋 찾은 템플릿:', { // found: !!template, // templateId, // availableTemplates: freshTemplates.length, // template: template ? { // id: template.id, // name: template.name, // componentsCount: template.components?.length || 0 // } : null // }); if (template) { // 🎯 templates state도 원본으로 업데이트 (깨끗한 상태) setTemplates(freshTemplates); setSelectedTemplateId(templateId); setSubject(template.subject); const vars = extractTemplateVariables(template); setTemplateVariables(vars); const initialVars: Record = {}; vars.forEach((v) => { initialVars[v] = ""; }); setVariables(initialVars); // console.log('✅ 원본 템플릿 적용 완료 (깨끗한 상태):', { // subject: template.subject, // variables: vars // }); } else { setSelectedTemplateId(""); setTemplateVariables([]); setVariables({}); // console.warn('⚠️ 템플릿을 찾을 수 없음'); } } catch (error) { // console.error('❌ 템플릿 재로드 실패:', error); toast({ title: "템플릿 로드 실패", description: "템플릿을 불러오는 중 오류가 발생했습니다.", variant: "destructive", }); } }; // 이메일 태그 입력 처리 (쉼표, 엔터, 공백 시 추가) const handleEmailInput = ( e: KeyboardEvent, type: "to" | "cc" | "bcc" ) => { const input = type === "to" ? toInput : type === "cc" ? ccInput : bccInput; const setInput = type === "to" ? setToInput : type === "cc" ? setCcInput : setBccInput; const emails = type === "to" ? to : type === "cc" ? cc : bcc; const setEmails = type === "to" ? setTo : type === "cc" ? setCc : setBcc; if (e.key === "Enter" || e.key === "," || e.key === " ") { e.preventDefault(); const trimmedInput = input.trim().replace(/,$/, ""); if (trimmedInput && isValidEmail(trimmedInput)) { if (!emails.includes(trimmedInput)) { setEmails([...emails, trimmedInput]); } setInput(""); } else if (trimmedInput) { toast({ title: "잘못된 이메일 형식", description: `"${trimmedInput}"은(는) 올바른 이메일 주소가 아닙니다.`, variant: "destructive", }); } } }; // 이메일 주소 유효성 검사 const isValidEmail = (email: string) => { const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; return regex.test(email); }; // 이메일 태그 제거 const removeEmail = (email: string, type: "to" | "cc" | "bcc") => { if (type === "to") { setTo(to.filter((e) => e !== email)); } else if (type === "cc") { setCc(cc.filter((e) => e !== email)); } else { setBcc(bcc.filter((e) => e !== email)); } }; // 텍스트를 HTML로 변환 (줄바꿈 처리) const convertTextToHtml = (text: string) => { // 줄바꿈을
로 변환하고 단락으로 감싸기 const paragraphs = text.split('\n\n').filter(p => p.trim()); const html = paragraphs .map(p => `

${p.replace(/\n/g, '
')}

`) .join(''); return `
${html}
`; }; // 메일 발송 const handleSendMail = async () => { if (!selectedAccountId) { toast({ title: "계정 선택 필요", description: "발송할 메일 계정을 선택해주세요.", variant: "destructive", }); return; } if (to.length === 0) { toast({ title: "수신자 필요", description: "받는 사람을 1명 이상 입력해주세요.", variant: "destructive", }); return; } if (!subject.trim()) { toast({ title: "제목 필요", description: "메일 제목을 입력해주세요.", variant: "destructive", }); return; } if (!selectedTemplateId && !customHtml.trim()) { toast({ title: "내용 필요", description: "템플릿을 선택하거나 메일 내용을 입력해주세요.", variant: "destructive", }); return; } try { setSending(true); // HTML 변환 let htmlContent = undefined; if (customHtml.trim()) { // 일반 텍스트를 HTML로 변환 htmlContent = convertTextToHtml(customHtml); } // FormData 생성 (파일 첨부 지원) const formData = new FormData(); formData.append("accountId", selectedAccountId); if (selectedTemplateId) { formData.append("templateId", selectedTemplateId); // 🎯 수정된 템플릿 컴포넌트 전송 const currentTemplate = templates.find((t) => t.id === selectedTemplateId); if (currentTemplate) { formData.append("modifiedTemplateComponents", JSON.stringify(currentTemplate.components)); // console.log('📤 수정된 템플릿 컴포넌트 전송:', currentTemplate.components.length); } } formData.append("to", JSON.stringify(to)); if (cc.length > 0) { formData.append("cc", JSON.stringify(cc)); } if (bcc.length > 0) { formData.append("bcc", JSON.stringify(bcc)); } formData.append("subject", subject); if (variables && Object.keys(variables).length > 0) { formData.append("variables", JSON.stringify(variables)); } if (htmlContent) { formData.append("customHtml", htmlContent); } // 첨부파일 추가 (한글 파일명 처리) attachments.forEach((file) => { formData.append("attachments", file); }); // 원본 파일명을 JSON으로 전송 (한글 파일명 보존) if (attachments.length > 0) { const originalFileNames = attachments.map(file => { // 파일명 정규화 (NFD → NFC) const normalizedName = file.name.normalize('NFC'); // console.log('📎 파일명 정규화:', file.name, '->', normalizedName); return normalizedName; }); formData.append("fileNames", JSON.stringify(originalFileNames)); // console.log('📎 전송할 정규화된 파일명들:', originalFileNames); } // API 호출 (FormData 전송) const authToken = localStorage.getItem("authToken"); if (!authToken) { throw new Error("인증 토큰이 없습니다. 다시 로그인해주세요."); } const response = await fetch(`${API_BASE_URL}/mail/send/simple`, { method: "POST", headers: { Authorization: `Bearer ${authToken}`, }, body: formData, }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || "메일 발송 실패"); } // 성공 토스트 toast({ title: (
메일 발송 완료!
) as any, description: `${to.length}명${cc.length > 0 ? ` (참조 ${cc.length}명)` : ""}${bcc.length > 0 ? ` (숨은참조 ${bcc.length}명)` : ""}${attachments.length > 0 ? ` (첨부파일 ${attachments.length}개)` : ""}에게 메일이 성공적으로 발송되었습니다.`, className: "border-green-500 bg-green-50", }); // 알림 갱신 이벤트 발생 window.dispatchEvent(new CustomEvent('mail-sent')); // 폼 초기화 setTo([]); setCc([]); setBcc([]); setToInput(""); setCcInput(""); setBccInput(""); setSubject(""); setCustomHtml(""); setVariables({}); setSelectedTemplateId(""); setAttachments([]); } catch (error: unknown) { const err = error as Error; toast({ title: (
메일 발송 실패
) as any, description: err.message || "메일 발송 중 오류가 발생했습니다.", variant: "destructive", }); } finally { setSending(false); } }; // 임시 저장 const handleSaveDraft = async () => { try { setAutoSaving(true); const account = accounts.find(a => a.id === selectedAccountId); const draftData = { accountId: selectedAccountId, accountName: account?.name || "", accountEmail: account?.email || "", to, cc, bcc, subject, htmlContent: customHtml, templateId: selectedTemplateId || undefined, }; // console.log('💾 임시 저장 데이터:', draftData); if (draftId) { // 기존 임시 저장 업데이트 await updateDraft(draftId, draftData); // console.log('✏️ 임시 저장 업데이트 완료:', draftId); } else { // 새로운 임시 저장 const savedDraft = await saveDraft(draftData); // console.log('💾 임시 저장 완료:', savedDraft); if (savedDraft && savedDraft.id) { setDraftId(savedDraft.id); } } setLastSaved(new Date()); toast({ title: "임시 저장 완료", description: "작성 중인 메일이 저장되었습니다.", }); } catch (error: unknown) { const err = error as Error; // console.error('❌ 임시 저장 실패:', err); toast({ title: "임시 저장 실패", description: err.message || "임시 저장 중 오류가 발생했습니다.", variant: "destructive", }); } finally { setAutoSaving(false); } }; // 파일 첨부 관련 함수 const handleFileSelect = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); addFiles(files); // input 초기화 e.target.value = ""; }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(true); }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setIsDragging(false); const files = Array.from(e.dataTransfer.files); addFiles(files); }; const addFiles = (files: File[]) => { // 파일 검증 const validFiles = files.filter((file) => { // 파일 크기 제한 (10MB) if (file.size > 10 * 1024 * 1024) { toast({ title: "파일 크기 초과", description: `${file.name}은(는) 10MB를 초과합니다.`, variant: "destructive", }); return false; } // 위험한 확장자 차단 const dangerousExtensions = [".exe", ".bat", ".cmd", ".sh", ".ps1", ".msi"]; const extension = file.name.toLowerCase().substring(file.name.lastIndexOf(".")); if (dangerousExtensions.includes(extension)) { toast({ title: "허용되지 않는 파일 형식", description: `${extension} 파일은 첨부할 수 없습니다.`, variant: "destructive", }); return false; } return true; }); // 최대 5개 제한 const totalFiles = attachments.length + validFiles.length; if (totalFiles > 5) { toast({ title: "파일 개수 초과", description: "최대 5개까지만 첨부할 수 있습니다.", variant: "destructive", }); return; } setAttachments([...attachments, ...validFiles]); }; const removeFile = (index: number) => { setAttachments(attachments.filter((_, i) => i !== index)); }; const formatFileSize = (bytes: number) => { if (bytes === 0) return "0 Bytes"; const k = 1024; const sizes = ["Bytes", "KB", "MB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i]; }; // 미리보기 const handlePreview = () => { console.log('👁️ 미리보기 토글:', { current: showPreview, willBe: !showPreview, selectedTemplateId, hasCustomHtml: !!customHtml }); setShowPreview(!showPreview); }; const getPreviewHtml = () => { if (selectedTemplateId) { const template = templates.find((t) => t.id === selectedTemplateId); if (template) { // console.log('🎨 템플릿 미리보기:', { // templateId: selectedTemplateId, // templateName: template.name, // componentsCount: template.components?.length || 0, // components: template.components, // variables // }); const html = renderTemplateToHtml(template, variables); // console.log('📄 생성된 HTML:', html.substring(0, 200) + '...'); // 추가 메시지가 있으면 병합 if (customHtml && customHtml.trim()) { const additionalContent = `
${convertTextToHtml(customHtml)}
`; return html + additionalContent; } return html; } } // 일반 텍스트를 HTML로 변환하여 미리보기 return customHtml ? convertTextToHtml(customHtml) : ""; }; if (loading) { return (
); } return (
{/* 헤더 */}
{/* 브레드크럼브 */} {/* 제목 */}

{subject.startsWith("Re: ") ? "답장 작성" : subject.startsWith("Fwd: ") ? "메일 전달" : "메일 발송"}

{subject.startsWith("Re: ") ? "받은 메일에 답장을 작성합니다" : subject.startsWith("Fwd: ") ? "메일을 다른 사람에게 전달합니다" : "템플릿을 선택하거나 직접 작성하여 메일을 발송하세요"}

{/* 임시 저장 표시 */} {lastSaved && (
{autoSaving ? ( <> 저장 중... ) : ( <> {new Date(lastSaved).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })} 임시 저장됨 )}
)} {/* 임시 저장 버튼 */} {/* 임시 저장 목록 버튼 */}
{/* 메일 작성 폼 */}
{/* 발송 설정 */} 발송 설정 {/* 발송 계정 선택 */}
{/* 템플릿 선택 */}
{/* 수신자 */} 수신자 {/* 받는 사람 */}
{to.map((email) => (
{email}
))} setToInput(e.target.value)} onKeyDown={(e) => handleEmailInput(e, "to")} placeholder={to.length === 0 ? "이메일 주소 입력 후 엔터, 쉼표, 스페이스" : ""} className="flex-1 outline-none min-w-[200px] text-sm" />

💡 이메일 주소를 입력하고 엔터, 쉼표(,), 스페이스를 눌러 추가하세요

{/* 참조 (CC) */}
{cc.map((email) => (
{email}
))} setCcInput(e.target.value)} onKeyDown={(e) => handleEmailInput(e, "cc")} placeholder={cc.length === 0 ? "참조로 받을 이메일 주소" : ""} className="flex-1 outline-none min-w-[200px] text-sm" />

다른 수신자에게도 공개됩니다

{/* 숨은참조 (BCC) */}
{bcc.map((email) => (
{email}
))} setBccInput(e.target.value)} onKeyDown={(e) => handleEmailInput(e, "bcc")} placeholder={bcc.length === 0 ? "숨은참조로 받을 이메일 주소" : ""} className="flex-1 outline-none min-w-[200px] text-sm" />

🔒 다른 수신자에게 보이지 않습니다 (모니터링용)

{/* 메일 내용 */} 메일 내용 {/* 제목 */}
setSubject(e.target.value)} placeholder="메일 제목을 입력하세요" />
{/* 템플릿 변수 입력 */} {templateVariables.length > 0 && (
{templateVariables.map((varName) => (
setVariables({ ...variables, [varName]: e.target.value }) } placeholder={`{${varName}} 변수 값`} />
))}
)} {/* 템플릿 편집 가능한 미리보기 */} {selectedTemplateId && (() => { const template = templates.find((t) => t.id === selectedTemplateId); if (template) { return (
{template.components.map((component, index) => { // 변수 치환 및 HTML 태그 제거 let content = component.content || ''; let text = component.text || ''; // HTML 태그 제거 (일반 텍스트만 표시) const stripHtml = (html: string) => { const tmp = document.createElement("DIV"); tmp.innerHTML = html; return tmp.textContent || tmp.innerText || ""; }; content = stripHtml(content); if (variables) { Object.entries(variables).forEach(([key, value]) => { content = content.replace(new RegExp(`\\{${key}\\}`, 'g'), value); text = text.replace(new RegExp(`\\{${key}\\}`, 'g'), value); }); } switch (component.type) { case 'text': return (