2025-10-01 16:15:53 +09:00
|
|
|
|
"use client";
|
|
|
|
|
|
|
2025-10-01 17:01:31 +09:00
|
|
|
|
import React, { useState, useEffect } from "react";
|
2025-10-01 16:15:53 +09:00
|
|
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
2025-10-01 17:01:31 +09:00
|
|
|
|
import {
|
|
|
|
|
|
Inbox,
|
|
|
|
|
|
Mail,
|
|
|
|
|
|
RefreshCw,
|
|
|
|
|
|
Loader2,
|
|
|
|
|
|
CheckCircle,
|
|
|
|
|
|
Paperclip,
|
|
|
|
|
|
AlertCircle,
|
|
|
|
|
|
Search,
|
|
|
|
|
|
Filter,
|
|
|
|
|
|
SortAsc,
|
|
|
|
|
|
SortDesc,
|
2025-10-13 15:17:34 +09:00
|
|
|
|
ChevronRight,
|
2025-10-22 16:06:04 +09:00
|
|
|
|
Reply,
|
|
|
|
|
|
Forward,
|
|
|
|
|
|
Trash2,
|
2025-10-01 17:01:31 +09:00
|
|
|
|
} from "lucide-react";
|
2025-10-22 16:06:04 +09:00
|
|
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
2025-10-13 15:17:34 +09:00
|
|
|
|
import Link from "next/link";
|
|
|
|
|
|
import { Separator } from "@/components/ui/separator";
|
2025-10-01 17:01:31 +09:00
|
|
|
|
import {
|
|
|
|
|
|
MailAccount,
|
|
|
|
|
|
ReceivedMail,
|
2025-10-22 16:06:04 +09:00
|
|
|
|
MailDetail,
|
2025-10-01 17:01:31 +09:00
|
|
|
|
getMailAccounts,
|
|
|
|
|
|
getReceivedMails,
|
|
|
|
|
|
testImapConnection,
|
2025-10-22 16:06:04 +09:00
|
|
|
|
getMailDetail,
|
|
|
|
|
|
markMailAsRead,
|
|
|
|
|
|
downloadMailAttachment,
|
2025-10-01 17:01:31 +09:00
|
|
|
|
} from "@/lib/api/mail";
|
2025-10-22 16:06:04 +09:00
|
|
|
|
import { apiClient } from "@/lib/api/client";
|
|
|
|
|
|
import DOMPurify from "isomorphic-dompurify";
|
2025-10-01 16:15:53 +09:00
|
|
|
|
|
|
|
|
|
|
export default function MailReceivePage() {
|
2025-10-02 18:22:58 +09:00
|
|
|
|
const router = useRouter();
|
2025-10-22 16:06:04 +09:00
|
|
|
|
const searchParams = useSearchParams();
|
2025-10-01 17:01:31 +09:00
|
|
|
|
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
|
|
|
|
|
const [selectedAccountId, setSelectedAccountId] = useState<string>("");
|
|
|
|
|
|
const [mails, setMails] = useState<ReceivedMail[]>([]);
|
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
const [testing, setTesting] = useState(false);
|
|
|
|
|
|
const [testResult, setTestResult] = useState<{
|
|
|
|
|
|
success: boolean;
|
|
|
|
|
|
message: string;
|
|
|
|
|
|
} | null>(null);
|
|
|
|
|
|
|
2025-10-22 16:06:04 +09:00
|
|
|
|
// 메일 상세 상태 (모달 대신 패널)
|
2025-10-01 17:01:31 +09:00
|
|
|
|
const [selectedMailId, setSelectedMailId] = useState<string>("");
|
2025-10-22 16:06:04 +09:00
|
|
|
|
const [selectedMailDetail, setSelectedMailDetail] = useState<MailDetail | null>(null);
|
|
|
|
|
|
const [loadingDetail, setLoadingDetail] = useState(false);
|
|
|
|
|
|
const [deleting, setDeleting] = useState(false);
|
2025-10-01 17:01:31 +09:00
|
|
|
|
|
|
|
|
|
|
// 검색 및 필터 상태
|
|
|
|
|
|
const [searchTerm, setSearchTerm] = useState<string>("");
|
|
|
|
|
|
const [filterStatus, setFilterStatus] = useState<string>("all"); // all, unread, read, attachment
|
|
|
|
|
|
const [sortBy, setSortBy] = useState<string>("date-desc"); // date-desc, date-asc, from-asc, from-desc
|
|
|
|
|
|
|
2025-10-22 17:07:38 +09:00
|
|
|
|
// 페이지네이션
|
|
|
|
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
|
|
|
|
const [itemsPerPage] = useState(10);
|
|
|
|
|
|
const [totalPages, setTotalPages] = useState(1);
|
|
|
|
|
|
const [allMails, setAllMails] = useState<ReceivedMail[]>([]); // 전체 메일 저장
|
|
|
|
|
|
|
2025-10-01 17:01:31 +09:00
|
|
|
|
// 계정 목록 로드
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
loadAccounts();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
// 계정 선택 시 메일 로드
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (selectedAccountId) {
|
2025-10-22 17:07:38 +09:00
|
|
|
|
setCurrentPage(1); // 계정 변경 시 첫 페이지로
|
2025-10-01 17:01:31 +09:00
|
|
|
|
loadMails();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [selectedAccountId]);
|
|
|
|
|
|
|
2025-10-22 17:07:38 +09:00
|
|
|
|
// 페이지 변경 시 페이지네이션 재적용
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (allMails.length > 0) {
|
|
|
|
|
|
applyPagination(allMails);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [currentPage]);
|
|
|
|
|
|
|
2025-10-22 16:06:04 +09:00
|
|
|
|
// URL 파라미터에서 mailId 읽기 및 자동 선택
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const mailId = searchParams.get('mailId');
|
|
|
|
|
|
const accountId = searchParams.get('accountId');
|
|
|
|
|
|
|
|
|
|
|
|
if (mailId && accountId) {
|
2025-10-22 17:07:38 +09:00
|
|
|
|
// console.log('📧 URL에서 메일 ID 감지:', mailId, accountId);
|
2025-10-22 16:06:04 +09:00
|
|
|
|
setSelectedAccountId(accountId);
|
|
|
|
|
|
setSelectedMailId(mailId);
|
|
|
|
|
|
// 메일 상세 로드는 handleMailClick에서 처리됨
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [searchParams]);
|
|
|
|
|
|
|
|
|
|
|
|
// 메일 목록 로드 후 URL에서 지정된 메일 자동 선택
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (selectedMailId && mails.length > 0 && !selectedMailDetail) {
|
|
|
|
|
|
const mail = mails.find(m => m.id === selectedMailId);
|
|
|
|
|
|
if (mail) {
|
2025-10-22 17:07:38 +09:00
|
|
|
|
// console.log('🎯 URL에서 지정된 메일 자동 선택:', selectedMailId);
|
2025-10-22 16:06:04 +09:00
|
|
|
|
handleMailClick(mail);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [mails, selectedMailId, selectedMailDetail]); // selectedMailDetail 추가로 무한 루프 방지
|
|
|
|
|
|
|
2025-10-01 17:01:31 +09:00
|
|
|
|
// 자동 새로고침 (30초마다)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!selectedAccountId) return;
|
|
|
|
|
|
|
|
|
|
|
|
const interval = setInterval(() => {
|
|
|
|
|
|
loadMails();
|
|
|
|
|
|
}, 30000); // 30초
|
|
|
|
|
|
|
|
|
|
|
|
return () => clearInterval(interval);
|
|
|
|
|
|
}, [selectedAccountId]);
|
|
|
|
|
|
|
|
|
|
|
|
const loadAccounts = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await getMailAccounts();
|
|
|
|
|
|
if (Array.isArray(data)) {
|
|
|
|
|
|
const activeAccounts = data.filter((acc) => acc.status === "active");
|
|
|
|
|
|
setAccounts(activeAccounts);
|
|
|
|
|
|
if (activeAccounts.length > 0 && !selectedAccountId) {
|
|
|
|
|
|
setSelectedAccountId(activeAccounts[0].id);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
2025-10-22 17:07:38 +09:00
|
|
|
|
// console.error("계정 로드 실패:", error);
|
2025-10-01 17:01:31 +09:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const loadMails = async () => {
|
|
|
|
|
|
if (!selectedAccountId) return;
|
|
|
|
|
|
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
setTestResult(null);
|
|
|
|
|
|
try {
|
2025-10-22 17:07:38 +09:00
|
|
|
|
const data = await getReceivedMails(selectedAccountId, 200); // 더 많이 가져오기
|
2025-10-22 16:06:04 +09:00
|
|
|
|
|
|
|
|
|
|
// 현재 로컬에서 읽음 처리한 메일들의 상태를 유지
|
2025-10-22 17:07:38 +09:00
|
|
|
|
const processedMails = data.map(mail => ({
|
|
|
|
|
|
...mail,
|
|
|
|
|
|
isRead: mail.isRead
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
setAllMails(processedMails); // 전체 메일 저장
|
|
|
|
|
|
|
|
|
|
|
|
// 페이지네이션 적용
|
|
|
|
|
|
applyPagination(processedMails);
|
2025-10-22 16:06:04 +09:00
|
|
|
|
|
|
|
|
|
|
// 알림 갱신 이벤트 발생 (새 메일이 있을 수 있음)
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent('mail-received'));
|
2025-10-01 17:01:31 +09:00
|
|
|
|
} catch (error) {
|
2025-10-22 17:07:38 +09:00
|
|
|
|
// console.error("메일 로드 실패:", error);
|
2025-10-01 17:01:31 +09:00
|
|
|
|
alert(
|
|
|
|
|
|
error instanceof Error
|
|
|
|
|
|
? error.message
|
|
|
|
|
|
: "메일을 불러오는데 실패했습니다."
|
|
|
|
|
|
);
|
|
|
|
|
|
setMails([]);
|
2025-10-22 17:07:38 +09:00
|
|
|
|
setAllMails([]);
|
2025-10-01 17:01:31 +09:00
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-22 17:07:38 +09:00
|
|
|
|
const applyPagination = (mailList: ReceivedMail[]) => {
|
|
|
|
|
|
const totalItems = mailList.length;
|
|
|
|
|
|
const totalPagesCalc = Math.ceil(totalItems / itemsPerPage);
|
|
|
|
|
|
setTotalPages(totalPagesCalc);
|
|
|
|
|
|
|
|
|
|
|
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
|
|
|
|
|
const endIndex = startIndex + itemsPerPage;
|
|
|
|
|
|
const paginatedMails = mailList.slice(startIndex, endIndex);
|
|
|
|
|
|
|
|
|
|
|
|
setMails(paginatedMails);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-01 17:01:31 +09:00
|
|
|
|
const handleTestConnection = async () => {
|
|
|
|
|
|
if (!selectedAccountId) return;
|
|
|
|
|
|
|
|
|
|
|
|
setTesting(true);
|
|
|
|
|
|
setTestResult(null);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await testImapConnection(selectedAccountId);
|
|
|
|
|
|
setTestResult(result);
|
|
|
|
|
|
if (result.success) {
|
|
|
|
|
|
// 연결 성공 후 자동으로 메일 로드
|
|
|
|
|
|
setTimeout(() => loadMails(), 1000);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
setTestResult({
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
message:
|
|
|
|
|
|
error instanceof Error
|
|
|
|
|
|
? error.message
|
|
|
|
|
|
: "IMAP 연결 테스트 실패",
|
|
|
|
|
|
});
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setTesting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const formatDate = (dateString: string) => {
|
|
|
|
|
|
const date = new Date(dateString);
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
const diffMs = now.getTime() - date.getTime();
|
|
|
|
|
|
const diffMins = Math.floor(diffMs / 60000);
|
|
|
|
|
|
const diffHours = Math.floor(diffMs / 3600000);
|
|
|
|
|
|
const diffDays = Math.floor(diffMs / 86400000);
|
|
|
|
|
|
|
|
|
|
|
|
if (diffMins < 60) {
|
|
|
|
|
|
return `${diffMins}분 전`;
|
|
|
|
|
|
} else if (diffHours < 24) {
|
|
|
|
|
|
return `${diffHours}시간 전`;
|
|
|
|
|
|
} else if (diffDays < 7) {
|
|
|
|
|
|
return `${diffDays}일 전`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return date.toLocaleDateString("ko-KR");
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-22 16:06:04 +09:00
|
|
|
|
const handleMailClick = async (mail: ReceivedMail) => {
|
2025-10-01 17:01:31 +09:00
|
|
|
|
setSelectedMailId(mail.id);
|
2025-10-22 16:06:04 +09:00
|
|
|
|
setLoadingDetail(true);
|
|
|
|
|
|
|
|
|
|
|
|
// 즉시 로컬 상태 업데이트 (UI 반응성 향상)
|
2025-10-22 17:07:38 +09:00
|
|
|
|
// console.log('📧 메일 클릭:', mail.id, '현재 읽음 상태:', mail.isRead);
|
2025-10-22 16:06:04 +09:00
|
|
|
|
setMails((prevMails) =>
|
|
|
|
|
|
prevMails.map((m) =>
|
|
|
|
|
|
m.id === mail.id ? { ...m, isRead: true } : m
|
|
|
|
|
|
)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
// 메일 상세 정보 로드
|
|
|
|
|
|
try {
|
|
|
|
|
|
// mail.id에서 accountId와 seqno 추출: "account-{timestamp}-{seqno}" 형식
|
|
|
|
|
|
const mailIdParts = mail.id.split('-');
|
|
|
|
|
|
const accountId = `${mailIdParts[0]}-${mailIdParts[1]}`; // "account-1759310844272"
|
|
|
|
|
|
const seqno = parseInt(mailIdParts[2], 10); // 13
|
|
|
|
|
|
|
2025-10-22 17:07:38 +09:00
|
|
|
|
// console.log('🔍 추출된 accountId:', accountId, 'seqno:', seqno, '원본 mailId:', mail.id);
|
2025-10-22 16:06:04 +09:00
|
|
|
|
|
|
|
|
|
|
const detail = await getMailDetail(accountId, seqno);
|
|
|
|
|
|
setSelectedMailDetail(detail);
|
|
|
|
|
|
|
|
|
|
|
|
// 읽음 처리
|
|
|
|
|
|
if (!mail.isRead) {
|
|
|
|
|
|
await markMailAsRead(accountId, seqno);
|
2025-10-22 17:07:38 +09:00
|
|
|
|
// console.log('✅ 읽음 처리 완료 - seqno:', seqno);
|
2025-10-22 16:06:04 +09:00
|
|
|
|
|
|
|
|
|
|
// 서버 상태 동기화 (백그라운드) - IMAP 서버 반영 대기
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
if (selectedAccountId) {
|
2025-10-22 17:07:38 +09:00
|
|
|
|
// console.log('🔄 서버 상태 동기화 시작');
|
2025-10-22 16:06:04 +09:00
|
|
|
|
loadMails();
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 2000); // 2초로 증가
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
2025-10-22 17:07:38 +09:00
|
|
|
|
// console.error('메일 상세 로드 실패:', error);
|
2025-10-22 16:06:04 +09:00
|
|
|
|
} finally {
|
|
|
|
|
|
setLoadingDetail(false);
|
|
|
|
|
|
}
|
2025-10-01 17:01:31 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-22 16:06:04 +09:00
|
|
|
|
const handleDeleteMail = async () => {
|
|
|
|
|
|
if (!selectedMailId || !confirm("이 메일을 IMAP 서버에서 삭제하시겠습니까?\n(Gmail/Naver 휴지통으로 이동됩니다)\n\n⚠️ IMAP 연결에 시간이 걸릴 수 있습니다.")) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
setDeleting(true);
|
|
|
|
|
|
|
|
|
|
|
|
// mail.id에서 accountId와 seqno 추출: "account-{timestamp}-{seqno}" 형식
|
|
|
|
|
|
const mailIdParts = selectedMailId.split('-');
|
|
|
|
|
|
const accountId = `${mailIdParts[0]}-${mailIdParts[1]}`; // "account-1759310844272"
|
|
|
|
|
|
const seqno = parseInt(mailIdParts[2], 10); // 10
|
|
|
|
|
|
|
2025-10-22 17:07:38 +09:00
|
|
|
|
// console.log(`🗑️ 메일 삭제 시도: accountId=${accountId}, seqno=${seqno}`);
|
2025-10-22 16:06:04 +09:00
|
|
|
|
|
|
|
|
|
|
// IMAP 서버에서 메일 삭제 (타임아웃 40초)
|
|
|
|
|
|
const response = await apiClient.delete(`/mail/receive/${accountId}/${seqno}`, {
|
|
|
|
|
|
timeout: 40000, // 40초 타임아웃
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (response.data.success) {
|
|
|
|
|
|
// 메일 목록에서 제거
|
|
|
|
|
|
setMails(mails.filter((m) => m.id !== selectedMailId));
|
|
|
|
|
|
|
|
|
|
|
|
// 상세 패널 닫기
|
|
|
|
|
|
setSelectedMailId("");
|
|
|
|
|
|
setSelectedMailDetail(null);
|
|
|
|
|
|
|
|
|
|
|
|
alert("메일이 삭제되었습니다.");
|
2025-10-22 17:07:38 +09:00
|
|
|
|
// console.log("✅ 메일 삭제 완료");
|
2025-10-22 16:06:04 +09:00
|
|
|
|
}
|
|
|
|
|
|
} catch (error: any) {
|
2025-10-22 17:07:38 +09:00
|
|
|
|
// console.error("메일 삭제 실패:", error);
|
2025-10-22 16:06:04 +09:00
|
|
|
|
|
|
|
|
|
|
let errorMessage = "메일 삭제에 실패했습니다.";
|
|
|
|
|
|
|
|
|
|
|
|
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
|
|
|
|
|
|
errorMessage = "IMAP 서버 연결 시간 초과\n네트워크 상태를 확인하거나 나중에 다시 시도해주세요.";
|
|
|
|
|
|
} else if (error.response?.data?.message) {
|
|
|
|
|
|
errorMessage = error.response.data.message;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
alert(errorMessage);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setDeleting(false);
|
|
|
|
|
|
}
|
2025-10-01 17:01:31 +09:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 필터링 및 정렬된 메일 목록
|
|
|
|
|
|
const filteredAndSortedMails = React.useMemo(() => {
|
|
|
|
|
|
let result = [...mails];
|
|
|
|
|
|
|
|
|
|
|
|
// 검색
|
|
|
|
|
|
if (searchTerm) {
|
|
|
|
|
|
const searchLower = searchTerm.toLowerCase();
|
|
|
|
|
|
result = result.filter(
|
|
|
|
|
|
(mail) =>
|
|
|
|
|
|
mail.subject.toLowerCase().includes(searchLower) ||
|
|
|
|
|
|
mail.from.toLowerCase().includes(searchLower) ||
|
|
|
|
|
|
mail.preview.toLowerCase().includes(searchLower)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 필터
|
|
|
|
|
|
if (filterStatus === "unread") {
|
|
|
|
|
|
result = result.filter((mail) => !mail.isRead);
|
|
|
|
|
|
} else if (filterStatus === "read") {
|
|
|
|
|
|
result = result.filter((mail) => mail.isRead);
|
|
|
|
|
|
} else if (filterStatus === "attachment") {
|
|
|
|
|
|
result = result.filter((mail) => mail.hasAttachments);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 정렬
|
|
|
|
|
|
if (sortBy === "date-desc") {
|
|
|
|
|
|
result.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
|
|
|
|
|
} else if (sortBy === "date-asc") {
|
|
|
|
|
|
result.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
|
|
|
|
|
} else if (sortBy === "from-asc") {
|
|
|
|
|
|
result.sort((a, b) => a.from.localeCompare(b.from));
|
|
|
|
|
|
} else if (sortBy === "from-desc") {
|
|
|
|
|
|
result.sort((a, b) => b.from.localeCompare(a.from));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
|
}, [mails, searchTerm, filterStatus, sortBy]);
|
|
|
|
|
|
|
2025-10-01 16:15:53 +09:00
|
|
|
|
return (
|
2025-10-13 15:17:34 +09:00
|
|
|
|
<div className="min-h-screen bg-background">
|
2025-10-01 16:15:53 +09:00
|
|
|
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
|
|
|
|
|
{/* 페이지 제목 */}
|
2025-10-13 15:17:34 +09:00
|
|
|
|
<div className="bg-card rounded-lg border p-6 space-y-4">
|
|
|
|
|
|
{/* 브레드크럼브 */}
|
|
|
|
|
|
<nav className="flex items-center gap-2 text-sm">
|
|
|
|
|
|
<Link
|
|
|
|
|
|
href="/admin/mail/dashboard"
|
|
|
|
|
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
2025-10-02 18:22:58 +09:00
|
|
|
|
>
|
2025-10-13 15:17:34 +09:00
|
|
|
|
메일 관리
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
|
|
|
|
|
<span className="text-foreground font-medium">메일 수신함</span>
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
|
|
|
|
|
|
<Separator />
|
|
|
|
|
|
|
|
|
|
|
|
{/* 제목 + 액션 버튼들 */}
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h1 className="text-3xl font-bold text-foreground">메일 수신함</h1>
|
|
|
|
|
|
<p className="mt-2 text-muted-foreground">
|
|
|
|
|
|
IMAP으로 받은 메일을 확인합니다
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex gap-2">
|
2025-10-01 17:01:31 +09:00
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={loadMails}
|
|
|
|
|
|
disabled={loading || !selectedAccountId}
|
|
|
|
|
|
>
|
|
|
|
|
|
<RefreshCw
|
|
|
|
|
|
className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`}
|
|
|
|
|
|
/>
|
|
|
|
|
|
새로고침
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={handleTestConnection}
|
|
|
|
|
|
disabled={testing || !selectedAccountId}
|
|
|
|
|
|
>
|
|
|
|
|
|
{testing ? (
|
|
|
|
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<CheckCircle className="w-4 h-4 mr-2" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
연결 테스트
|
|
|
|
|
|
</Button>
|
2025-10-13 15:17:34 +09:00
|
|
|
|
</div>
|
2025-10-01 16:15:53 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-10-01 17:01:31 +09:00
|
|
|
|
{/* 계정 선택 */}
|
2025-10-13 15:17:34 +09:00
|
|
|
|
<Card className="">
|
2025-10-01 17:01:31 +09:00
|
|
|
|
<CardContent className="p-4">
|
|
|
|
|
|
<div className="flex items-center gap-4">
|
2025-10-13 15:17:34 +09:00
|
|
|
|
<label className="text-sm font-medium text-foreground whitespace-nowrap">
|
2025-10-01 17:01:31 +09:00
|
|
|
|
메일 계정:
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={selectedAccountId}
|
|
|
|
|
|
onChange={(e) => setSelectedAccountId(e.target.value)}
|
2025-10-13 15:17:34 +09:00
|
|
|
|
className="flex-1 px-4 py-2 border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
2025-10-01 17:01:31 +09:00
|
|
|
|
>
|
|
|
|
|
|
<option value="">계정 선택</option>
|
|
|
|
|
|
{accounts.map((account) => (
|
|
|
|
|
|
<option key={account.id} value={account.id}>
|
|
|
|
|
|
{account.name} ({account.email})
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 연결 테스트 결과 */}
|
|
|
|
|
|
{testResult && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`mt-4 p-3 rounded-lg flex items-center gap-2 ${
|
|
|
|
|
|
testResult.success
|
|
|
|
|
|
? "bg-green-50 text-green-800 border border-green-200"
|
|
|
|
|
|
: "bg-red-50 text-red-800 border border-red-200"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{testResult.success ? (
|
|
|
|
|
|
<CheckCircle className="w-5 h-5" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<AlertCircle className="w-5 h-5" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span>{testResult.message}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 검색 및 필터 */}
|
|
|
|
|
|
{selectedAccountId && (
|
2025-10-13 15:17:34 +09:00
|
|
|
|
<Card className="">
|
2025-10-01 17:01:31 +09:00
|
|
|
|
<CardContent className="p-4">
|
|
|
|
|
|
<div className="flex flex-col md:flex-row gap-3">
|
|
|
|
|
|
{/* 검색 */}
|
|
|
|
|
|
<div className="flex-1 relative">
|
|
|
|
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
value={searchTerm}
|
|
|
|
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
|
|
|
|
placeholder="제목, 발신자, 내용으로 검색..."
|
2025-10-13 15:17:34 +09:00
|
|
|
|
className="w-full pl-10 pr-4 py-2 border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
2025-10-01 17:01:31 +09:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 필터 */}
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
2025-10-13 15:17:34 +09:00
|
|
|
|
<Filter className="w-4 h-4 text-muted-foreground" />
|
2025-10-01 17:01:31 +09:00
|
|
|
|
<select
|
|
|
|
|
|
value={filterStatus}
|
|
|
|
|
|
onChange={(e) => setFilterStatus(e.target.value)}
|
2025-10-13 15:17:34 +09:00
|
|
|
|
className="px-3 py-2 border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
2025-10-01 17:01:31 +09:00
|
|
|
|
>
|
|
|
|
|
|
<option value="all">전체</option>
|
|
|
|
|
|
<option value="unread">읽지 않음</option>
|
|
|
|
|
|
<option value="read">읽음</option>
|
|
|
|
|
|
<option value="attachment">첨부파일 있음</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 정렬 */}
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
{sortBy.includes("desc") ? (
|
2025-10-13 15:17:34 +09:00
|
|
|
|
<SortDesc className="w-4 h-4 text-muted-foreground" />
|
2025-10-01 17:01:31 +09:00
|
|
|
|
) : (
|
2025-10-13 15:17:34 +09:00
|
|
|
|
<SortAsc className="w-4 h-4 text-muted-foreground" />
|
2025-10-01 17:01:31 +09:00
|
|
|
|
)}
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={sortBy}
|
|
|
|
|
|
onChange={(e) => setSortBy(e.target.value)}
|
2025-10-13 15:17:34 +09:00
|
|
|
|
className="px-3 py-2 border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
2025-10-01 17:01:31 +09:00
|
|
|
|
>
|
|
|
|
|
|
<option value="date-desc">날짜 ↓ (최신순)</option>
|
|
|
|
|
|
<option value="date-asc">날짜 ↑ (오래된순)</option>
|
|
|
|
|
|
<option value="from-asc">발신자 ↑ (A-Z)</option>
|
|
|
|
|
|
<option value="from-desc">발신자 ↓ (Z-A)</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 검색 결과 카운트 */}
|
|
|
|
|
|
{(searchTerm || filterStatus !== "all") && (
|
2025-10-13 15:17:34 +09:00
|
|
|
|
<div className="mt-3 text-sm text-muted-foreground">
|
2025-10-01 17:01:31 +09:00
|
|
|
|
{filteredAndSortedMails.length}개의 메일이 검색되었습니다
|
|
|
|
|
|
{searchTerm && (
|
|
|
|
|
|
<span className="ml-2">
|
|
|
|
|
|
(검색어: <span className="font-medium text-orange-600">{searchTerm}</span>)
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-10-22 16:06:04 +09:00
|
|
|
|
{/* 네이버 메일 스타일 3-column 레이아웃 */}
|
|
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
|
|
|
|
{/* 왼쪽: 메일 목록 */}
|
|
|
|
|
|
<div className="lg:col-span-1">
|
|
|
|
|
|
{loading ? (
|
|
|
|
|
|
<Card className="">
|
|
|
|
|
|
<CardContent className="flex justify-center items-center py-16">
|
|
|
|
|
|
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
|
|
|
|
|
<span className="ml-3 text-muted-foreground">메일을 불러오는 중...</span>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
) : filteredAndSortedMails.length === 0 ? (
|
|
|
|
|
|
<Card className="text-center py-16 bg-card ">
|
|
|
|
|
|
<CardContent className="pt-6">
|
|
|
|
|
|
<Mail className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
|
|
|
|
|
<p className="text-muted-foreground mb-4">
|
|
|
|
|
|
{!selectedAccountId
|
|
|
|
|
|
? "메일 계정을 선택하세요"
|
|
|
|
|
|
: searchTerm || filterStatus !== "all"
|
|
|
|
|
|
? "검색 결과가 없습니다"
|
|
|
|
|
|
: "받은 메일이 없습니다"}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{selectedAccountId && (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
onClick={handleTestConnection}
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
disabled={testing}
|
|
|
|
|
|
>
|
|
|
|
|
|
{testing ? (
|
|
|
|
|
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<CheckCircle className="w-4 h-4 mr-2" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
IMAP 연결 테스트
|
|
|
|
|
|
</Button>
|
2025-10-01 17:01:31 +09:00
|
|
|
|
)}
|
2025-10-22 16:06:04 +09:00
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Card className="">
|
|
|
|
|
|
<CardHeader className="bg-gradient-to-r from-slate-50 to-gray-50 border-b">
|
|
|
|
|
|
<CardTitle className="flex items-center gap-2">
|
|
|
|
|
|
<Inbox className="w-5 h-5 text-orange-500" />
|
|
|
|
|
|
받은 메일함 ({filteredAndSortedMails.length}/{mails.length}개)
|
|
|
|
|
|
</CardTitle>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent className="p-0">
|
|
|
|
|
|
<div className="divide-y max-h-[calc(100vh-300px)] overflow-y-auto">
|
|
|
|
|
|
{filteredAndSortedMails.map((mail) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={mail.id}
|
|
|
|
|
|
onClick={() => handleMailClick(mail)}
|
|
|
|
|
|
className={`p-4 hover:bg-background transition-colors cursor-pointer ${
|
|
|
|
|
|
!mail.isRead ? "bg-blue-50/30" : ""
|
|
|
|
|
|
} ${selectedMailId === mail.id ? "bg-accent border-l-4 border-l-primary" : ""}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-start gap-4">
|
|
|
|
|
|
{/* 읽음 표시 */}
|
|
|
|
|
|
<div className="flex-shrink-0 w-2 h-2 mt-2">
|
|
|
|
|
|
{!mail.isRead && (
|
|
|
|
|
|
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
|
2025-10-01 17:01:31 +09:00
|
|
|
|
)}
|
2025-10-22 16:06:04 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 메일 내용 */}
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
|
<div className="flex items-center justify-between mb-1">
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={`text-sm ${
|
|
|
|
|
|
mail.isRead
|
|
|
|
|
|
? "text-muted-foreground"
|
|
|
|
|
|
: "text-foreground font-semibold"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{mail.from}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
{mail.hasAttachments && (
|
|
|
|
|
|
<Paperclip className="w-4 h-4 text-gray-400" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
|
{formatDate(mail.date)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<h3
|
|
|
|
|
|
className={`text-sm mb-1 truncate ${
|
|
|
|
|
|
mail.isRead ? "text-foreground" : "text-foreground font-medium"
|
|
|
|
|
|
}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
{mail.subject}
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground line-clamp-2">
|
|
|
|
|
|
{mail.preview}
|
|
|
|
|
|
</p>
|
2025-10-01 17:01:31 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-10-22 16:06:04 +09:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardContent>
|
2025-10-22 17:07:38 +09:00
|
|
|
|
|
|
|
|
|
|
{/* 페이지네이션 */}
|
|
|
|
|
|
{totalPages > 1 && (
|
|
|
|
|
|
<div className="flex items-center justify-center gap-2 p-4 border-t">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => setCurrentPage(1)}
|
|
|
|
|
|
disabled={currentPage === 1}
|
|
|
|
|
|
>
|
|
|
|
|
|
처음
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
|
|
|
|
|
disabled={currentPage === 1}
|
|
|
|
|
|
>
|
|
|
|
|
|
이전
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
|
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
|
|
|
|
|
let pageNum;
|
|
|
|
|
|
if (totalPages <= 5) {
|
|
|
|
|
|
pageNum = i + 1;
|
|
|
|
|
|
} else if (currentPage <= 3) {
|
|
|
|
|
|
pageNum = i + 1;
|
|
|
|
|
|
} else if (currentPage >= totalPages - 2) {
|
|
|
|
|
|
pageNum = totalPages - 4 + i;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
pageNum = currentPage - 2 + i;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
key={pageNum}
|
|
|
|
|
|
variant={currentPage === pageNum ? "default" : "outline"}
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => setCurrentPage(pageNum)}
|
|
|
|
|
|
className="w-8 h-8 p-0"
|
|
|
|
|
|
>
|
|
|
|
|
|
{pageNum}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
|
|
|
|
|
|
disabled={currentPage === totalPages}
|
|
|
|
|
|
>
|
|
|
|
|
|
다음
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => setCurrentPage(totalPages)}
|
|
|
|
|
|
disabled={currentPage === totalPages}
|
|
|
|
|
|
>
|
|
|
|
|
|
마지막
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-10-22 16:06:04 +09:00
|
|
|
|
</Card>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 오른쪽: 메일 상세 패널 */}
|
|
|
|
|
|
<div className="lg:col-span-1">
|
|
|
|
|
|
{selectedMailDetail ? (
|
|
|
|
|
|
<Card className="sticky top-6">
|
|
|
|
|
|
<CardHeader className="border-b">
|
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
|
<CardTitle className="text-lg">{selectedMailDetail.subject}</CardTitle>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setSelectedMailId("");
|
|
|
|
|
|
setSelectedMailDetail(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
✕
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="text-sm text-muted-foreground space-y-1 mt-2">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<span className="font-medium">보낸 사람:</span>
|
|
|
|
|
|
<span>{selectedMailDetail.from}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<span className="font-medium">받는 사람:</span>
|
|
|
|
|
|
<span>{selectedMailDetail.to}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<span className="font-medium">날짜:</span>
|
|
|
|
|
|
<span>{new Date(selectedMailDetail.date).toLocaleString("ko-KR")}</span>
|
2025-10-01 16:15:53 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-10-22 16:06:04 +09:00
|
|
|
|
|
|
|
|
|
|
{/* 답장/전달/삭제 버튼 */}
|
|
|
|
|
|
<div className="flex gap-2 mt-4">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
// HTML 태그 제거 함수 (강력한 버전)
|
|
|
|
|
|
const stripHtml = (html: string) => {
|
|
|
|
|
|
if (!html) return "";
|
|
|
|
|
|
|
|
|
|
|
|
// 1. DOMPurify로 먼저 정제
|
|
|
|
|
|
const cleanHtml = DOMPurify.sanitize(html, {
|
|
|
|
|
|
ALLOWED_TAGS: [], // 모든 태그 제거
|
|
|
|
|
|
KEEP_CONTENT: true // 내용만 유지
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 2. DOM으로 텍스트만 추출
|
|
|
|
|
|
const tmp = document.createElement("DIV");
|
|
|
|
|
|
tmp.innerHTML = cleanHtml;
|
|
|
|
|
|
let text = tmp.textContent || tmp.innerText || "";
|
|
|
|
|
|
|
|
|
|
|
|
// 3. CSS 스타일 제거 (p{...} 같은 패턴)
|
|
|
|
|
|
text = text.replace(/[a-z-]+\{[^}]*\}/gi, '');
|
|
|
|
|
|
|
|
|
|
|
|
// 4. 연속된 공백 정리
|
|
|
|
|
|
text = text.replace(/\s+/g, ' ').trim();
|
|
|
|
|
|
|
|
|
|
|
|
return text;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-22 17:07:38 +09:00
|
|
|
|
// console.log('📧 답장 데이터:', {
|
|
|
|
|
|
// htmlBody: selectedMailDetail.htmlBody,
|
|
|
|
|
|
// textBody: selectedMailDetail.textBody,
|
|
|
|
|
|
// });
|
2025-10-22 16:06:04 +09:00
|
|
|
|
|
|
|
|
|
|
// textBody 우선 사용 (순수 텍스트), 없으면 htmlBody에서 추출
|
|
|
|
|
|
const bodyText = selectedMailDetail.textBody
|
|
|
|
|
|
|| (selectedMailDetail.htmlBody ? stripHtml(selectedMailDetail.htmlBody) : "");
|
|
|
|
|
|
|
2025-10-22 17:07:38 +09:00
|
|
|
|
// console.log('📧 변환된 본문:', bodyText);
|
2025-10-22 16:06:04 +09:00
|
|
|
|
|
|
|
|
|
|
const replyData = {
|
|
|
|
|
|
originalFrom: selectedMailDetail.from,
|
|
|
|
|
|
originalSubject: selectedMailDetail.subject,
|
|
|
|
|
|
originalDate: selectedMailDetail.date,
|
|
|
|
|
|
originalBody: bodyText,
|
|
|
|
|
|
};
|
|
|
|
|
|
router.push(
|
|
|
|
|
|
`/admin/mail/send?action=reply&data=${encodeURIComponent(JSON.stringify(replyData))}`
|
|
|
|
|
|
);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Reply className="w-4 h-4 mr-1" />
|
|
|
|
|
|
답장
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
// HTML 태그 제거 함수 (강력한 버전)
|
|
|
|
|
|
const stripHtml = (html: string) => {
|
|
|
|
|
|
if (!html) return "";
|
|
|
|
|
|
|
|
|
|
|
|
// 1. DOMPurify로 먼저 정제
|
|
|
|
|
|
const cleanHtml = DOMPurify.sanitize(html, {
|
|
|
|
|
|
ALLOWED_TAGS: [], // 모든 태그 제거
|
|
|
|
|
|
KEEP_CONTENT: true // 내용만 유지
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 2. DOM으로 텍스트만 추출
|
|
|
|
|
|
const tmp = document.createElement("DIV");
|
|
|
|
|
|
tmp.innerHTML = cleanHtml;
|
|
|
|
|
|
let text = tmp.textContent || tmp.innerText || "";
|
|
|
|
|
|
|
|
|
|
|
|
// 3. CSS 스타일 제거 (p{...} 같은 패턴)
|
|
|
|
|
|
text = text.replace(/[a-z-]+\{[^}]*\}/gi, '');
|
|
|
|
|
|
|
|
|
|
|
|
// 4. 연속된 공백 정리
|
|
|
|
|
|
text = text.replace(/\s+/g, ' ').trim();
|
|
|
|
|
|
|
|
|
|
|
|
return text;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-22 17:07:38 +09:00
|
|
|
|
// console.log('📧 전달 데이터:', {
|
|
|
|
|
|
// htmlBody: selectedMailDetail.htmlBody,
|
|
|
|
|
|
// textBody: selectedMailDetail.textBody,
|
|
|
|
|
|
// });
|
2025-10-22 16:06:04 +09:00
|
|
|
|
|
|
|
|
|
|
// textBody 우선 사용 (순수 텍스트), 없으면 htmlBody에서 추출
|
|
|
|
|
|
const bodyText = selectedMailDetail.textBody
|
|
|
|
|
|
|| (selectedMailDetail.htmlBody ? stripHtml(selectedMailDetail.htmlBody) : "");
|
|
|
|
|
|
|
2025-10-22 17:07:38 +09:00
|
|
|
|
// console.log('📧 변환된 본문:', bodyText);
|
2025-10-22 16:06:04 +09:00
|
|
|
|
|
|
|
|
|
|
const forwardData = {
|
|
|
|
|
|
originalFrom: selectedMailDetail.from,
|
|
|
|
|
|
originalSubject: selectedMailDetail.subject,
|
|
|
|
|
|
originalDate: selectedMailDetail.date,
|
|
|
|
|
|
originalBody: bodyText,
|
|
|
|
|
|
};
|
|
|
|
|
|
router.push(
|
|
|
|
|
|
`/admin/mail/send?action=forward&data=${encodeURIComponent(JSON.stringify(forwardData))}`
|
|
|
|
|
|
);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Forward className="w-4 h-4 mr-1" />
|
|
|
|
|
|
전달
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant="destructive"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={handleDeleteMail}
|
|
|
|
|
|
disabled={deleting}
|
|
|
|
|
|
>
|
|
|
|
|
|
{deleting ? (
|
|
|
|
|
|
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Trash2 className="w-4 h-4 mr-1" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
삭제
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent className="p-6 max-h-[calc(100vh-300px)] overflow-y-auto">
|
|
|
|
|
|
{/* 첨부파일 */}
|
|
|
|
|
|
{selectedMailDetail.attachments && selectedMailDetail.attachments.length > 0 && (
|
|
|
|
|
|
<div className="mb-4 p-3 bg-muted rounded-lg">
|
|
|
|
|
|
<p className="text-sm font-medium mb-2">첨부파일 ({selectedMailDetail.attachments.length}개)</p>
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
|
{selectedMailDetail.attachments.map((att, index) => (
|
|
|
|
|
|
<div key={index} className="flex items-center gap-2 text-sm">
|
|
|
|
|
|
<Paperclip className="w-4 h-4" />
|
|
|
|
|
|
<span>{att.filename}</span>
|
|
|
|
|
|
<span className="text-muted-foreground">({(att.size / 1024).toFixed(1)} KB)</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 메일 본문 */}
|
|
|
|
|
|
{selectedMailDetail.htmlBody ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="prose prose-sm max-w-none"
|
|
|
|
|
|
dangerouslySetInnerHTML={{
|
|
|
|
|
|
__html: DOMPurify.sanitize(selectedMailDetail.htmlBody),
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="whitespace-pre-wrap text-sm">
|
|
|
|
|
|
{selectedMailDetail.textBody}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
) : loadingDetail ? (
|
|
|
|
|
|
<Card className="sticky top-6">
|
|
|
|
|
|
<CardContent className="flex justify-center items-center py-16">
|
|
|
|
|
|
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
|
|
|
|
|
<span className="ml-3 text-muted-foreground">메일을 불러오는 중...</span>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Card className="sticky top-6">
|
|
|
|
|
|
<CardContent className="flex flex-col justify-center items-center py-16 text-center">
|
|
|
|
|
|
<Mail className="w-16 h-16 mb-4 text-gray-300" />
|
|
|
|
|
|
<p className="text-muted-foreground">
|
|
|
|
|
|
메일을 선택하면 내용이 표시됩니다
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-10-01 16:15:53 +09:00
|
|
|
|
|
|
|
|
|
|
{/* 안내 정보 */}
|
2025-10-13 15:17:34 +09:00
|
|
|
|
<Card className="bg-gradient-to-r from-green-50 to-emerald-50 border-green-200 ">
|
2025-10-01 16:15:53 +09:00
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle className="text-lg flex items-center">
|
2025-10-01 17:01:31 +09:00
|
|
|
|
<CheckCircle className="w-5 h-5 mr-2 text-green-600" />
|
|
|
|
|
|
메일 수신 기능 완성! 🎉
|
2025-10-01 16:15:53 +09:00
|
|
|
|
</CardTitle>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent>
|
2025-10-13 15:17:34 +09:00
|
|
|
|
<p className="text-foreground mb-4">
|
2025-10-01 17:01:31 +09:00
|
|
|
|
✅ 구현 완료된 모든 기능:
|
2025-10-01 16:15:53 +09:00
|
|
|
|
</p>
|
2025-10-01 17:01:31 +09:00
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="font-medium text-gray-800 mb-2">📬 기본 기능</p>
|
2025-10-13 15:17:34 +09:00
|
|
|
|
<ul className="space-y-1 text-sm text-muted-foreground">
|
2025-10-01 17:01:31 +09:00
|
|
|
|
<li className="flex items-start">
|
|
|
|
|
|
<span className="text-green-500 mr-2">✓</span>
|
|
|
|
|
|
<span>IMAP 프로토콜 메일 수신</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
<li className="flex items-start">
|
|
|
|
|
|
<span className="text-green-500 mr-2">✓</span>
|
|
|
|
|
|
<span>메일 목록 표시</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
<li className="flex items-start">
|
|
|
|
|
|
<span className="text-green-500 mr-2">✓</span>
|
|
|
|
|
|
<span>읽음/안읽음 상태</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
<li className="flex items-start">
|
|
|
|
|
|
<span className="text-green-500 mr-2">✓</span>
|
|
|
|
|
|
<span>첨부파일 유무 표시</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="font-medium text-gray-800 mb-2">📄 상세보기</p>
|
2025-10-13 15:17:34 +09:00
|
|
|
|
<ul className="space-y-1 text-sm text-muted-foreground">
|
2025-10-01 17:01:31 +09:00
|
|
|
|
<li className="flex items-start">
|
|
|
|
|
|
<span className="text-green-500 mr-2">✓</span>
|
|
|
|
|
|
<span>HTML 본문 렌더링</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
<li className="flex items-start">
|
|
|
|
|
|
<span className="text-green-500 mr-2">✓</span>
|
|
|
|
|
|
<span>텍스트 본문 보기</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
<li className="flex items-start">
|
|
|
|
|
|
<span className="text-green-500 mr-2">✓</span>
|
|
|
|
|
|
<span>자동 읽음 처리</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
<li className="flex items-start">
|
|
|
|
|
|
<span className="text-green-500 mr-2">✓</span>
|
|
|
|
|
|
<span>첨부파일 다운로드</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="font-medium text-gray-800 mb-2">🔍 고급 기능</p>
|
2025-10-13 15:17:34 +09:00
|
|
|
|
<ul className="space-y-1 text-sm text-muted-foreground">
|
2025-10-01 17:01:31 +09:00
|
|
|
|
<li className="flex items-start">
|
|
|
|
|
|
<span className="text-green-500 mr-2">✓</span>
|
|
|
|
|
|
<span>통합 검색 (제목/발신자/내용)</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
<li className="flex items-start">
|
|
|
|
|
|
<span className="text-green-500 mr-2">✓</span>
|
|
|
|
|
|
<span>필터링 (읽음/첨부파일)</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
<li className="flex items-start">
|
|
|
|
|
|
<span className="text-green-500 mr-2">✓</span>
|
|
|
|
|
|
<span>정렬 (날짜/발신자)</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
<li className="flex items-start">
|
|
|
|
|
|
<span className="text-green-500 mr-2">✓</span>
|
|
|
|
|
|
<span>자동 새로고침 (30초)</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<p className="font-medium text-gray-800 mb-2">🔒 보안</p>
|
2025-10-13 15:17:34 +09:00
|
|
|
|
<ul className="space-y-1 text-sm text-muted-foreground">
|
2025-10-01 17:01:31 +09:00
|
|
|
|
<li className="flex items-start">
|
|
|
|
|
|
<span className="text-green-500 mr-2">✓</span>
|
|
|
|
|
|
<span>XSS 방지 (DOMPurify)</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
<li className="flex items-start">
|
|
|
|
|
|
<span className="text-green-500 mr-2">✓</span>
|
|
|
|
|
|
<span>비밀번호 암호화</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
<li className="flex items-start">
|
|
|
|
|
|
<span className="text-green-500 mr-2">✓</span>
|
|
|
|
|
|
<span>안전한 파일명 생성</span>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-10-01 16:15:53 +09:00
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|