"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, LayoutDashboard, } from "lucide-react"; import { useRouter } from "next/navigation"; import { MailAccount, MailTemplate, getMailAccounts, getMailTemplates, sendMail, extractTemplateVariables, renderTemplateToHtml, } from "@/lib/api/mail"; import { useToast } from "@/hooks/use-toast"; export default function MailSendPage() { const router = useRouter(); 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 [templateVariables, setTemplateVariables] = useState([]); // 첨부파일 const [attachments, setAttachments] = useState([]); const [isDragging, setIsDragging] = useState(false); useEffect(() => { loadData(); }, []); const loadData = async () => { try { setLoading(true); const [accountsData, templatesData] = await Promise.all([ getMailAccounts(), getMailTemplates(), ]); setAccounts(accountsData.filter((acc) => acc.status === "active")); setTemplates(templatesData); } catch (error: unknown) { const err = error as Error; toast({ title: "데이터 로드 실패", description: err.message, variant: "destructive", }); } finally { setLoading(false); } }; // 템플릿 선택 시 const handleTemplateChange = (templateId: string) => { // "__custom__"는 직접 작성을 의미 if (templateId === "__custom__") { setSelectedTemplateId(""); setTemplateVariables([]); setVariables({}); return; } setSelectedTemplateId(templateId); const template = templates.find((t) => t.id === templateId); if (template) { setSubject(template.subject); const vars = extractTemplateVariables(template); setTemplateVariables(vars); const initialVars: Record = {}; vars.forEach((v) => { initialVars[v] = ""; }); setVariables(initialVars); } else { setTemplateVariables([]); setVariables({}); } }; // 이메일 태그 입력 처리 (쉼표, 엔터, 공백 시 추가) 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로 자동 변환 const htmlContent = customHtml ? convertTextToHtml(customHtml) : undefined; // FormData 생성 (파일 첨부 지원) const formData = new FormData(); formData.append("accountId", selectedAccountId); if (selectedTemplateId) { formData.append("templateId", selectedTemplateId); } 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/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", }); // 폼 초기화 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 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 = () => { setShowPreview(!showPreview); }; const getPreviewHtml = () => { if (selectedTemplateId) { const template = templates.find((t) => t.id === selectedTemplateId); if (template) { return renderTemplateToHtml(template, variables); } } // 일반 텍스트를 HTML로 변환하여 미리보기 return customHtml ? convertTextToHtml(customHtml) : ""; }; if (loading) { return (
); } return (
{/* 헤더 */}

메일 발송

템플릿을 선택하거나 직접 작성하여 메일을 발송하세요

{/* 메일 작성 폼 */}
{/* 발송 설정 */} 발송 설정 {/* 발송 계정 선택 */}
{/* 템플릿 선택 */}
{/* 수신자 */} 수신자 {/* 받는 사람 */}
{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 && (