메일관리 콘솔로그 주석처리 세이브
This commit is contained in:
parent
bf58e0c878
commit
b4c5be1f17
196
UI_개선사항_문서.md
196
UI_개선사항_문서.md
|
|
@ -601,4 +601,200 @@ export default function EmptyStatePage() {
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 📧 메일 관리 시스템 UI 개선사항
|
||||||
|
|
||||||
|
### 최근 업데이트 (2025-01-02)
|
||||||
|
|
||||||
|
#### 1. 메일 발송 페이지 헤더 개선
|
||||||
|
**변경 전:**
|
||||||
|
```tsx
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-lg">
|
||||||
|
<Send className="w-6 h-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">메일 발송</h1>
|
||||||
|
<p className="text-sm text-gray-500">설명</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 후 (표준 헤더 카드 적용):**
|
||||||
|
```tsx
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-2xl">메일 발송</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
템플릿을 선택하거나 직접 작성하여 메일을 발송하세요
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
```
|
||||||
|
|
||||||
|
**개선 사항:**
|
||||||
|
- ✅ 불필요한 아이콘 제거 (종이비행기)
|
||||||
|
- ✅ 표준 Card 컴포넌트 사용으로 통일감 향상
|
||||||
|
- ✅ 다른 페이지와 동일한 헤더 스타일 적용
|
||||||
|
|
||||||
|
#### 2. 메일 내용 입력 개선
|
||||||
|
**변경 전:**
|
||||||
|
```tsx
|
||||||
|
<Textarea placeholder="메일 내용을 html로 작성하세요" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**변경 후:**
|
||||||
|
```tsx
|
||||||
|
<Textarea
|
||||||
|
placeholder="메일 내용을 입력하세요
|
||||||
|
|
||||||
|
줄바꿈은 자동으로 처리됩니다."
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
💡 일반 텍스트로 작성하면 자동으로 메일 형식으로 변환됩니다
|
||||||
|
</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
**개선 사항:**
|
||||||
|
- ✅ HTML 지식 없이도 사용 가능
|
||||||
|
- ✅ 일반 텍스트 입력 후 자동 HTML 변환
|
||||||
|
- ✅ 사용자 친화적인 안내 메시지
|
||||||
|
|
||||||
|
#### 3. CC/BCC 기능 추가
|
||||||
|
**구현 내용:**
|
||||||
|
```tsx
|
||||||
|
{/* To 태그 입력 */}
|
||||||
|
<EmailTagInput
|
||||||
|
tags={to}
|
||||||
|
onTagsChange={setTo}
|
||||||
|
placeholder="받는 사람 이메일"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* CC 태그 입력 */}
|
||||||
|
<EmailTagInput
|
||||||
|
tags={cc}
|
||||||
|
onTagsChange={setCc}
|
||||||
|
placeholder="참조 (선택사항)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* BCC 태그 입력 */}
|
||||||
|
<EmailTagInput
|
||||||
|
tags={bcc}
|
||||||
|
onTagsChange={setBcc}
|
||||||
|
placeholder="숨은참조 (선택사항)"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**특징:**
|
||||||
|
- ✅ 이메일 주소를 태그 형태로 시각화
|
||||||
|
- ✅ 쉼표로 구분하여 입력 가능
|
||||||
|
- ✅ 개별 삭제 가능
|
||||||
|
|
||||||
|
#### 4. 파일 첨부 기능 (Phase 1 완료)
|
||||||
|
|
||||||
|
**백엔드 구현:**
|
||||||
|
```typescript
|
||||||
|
// multer 설정
|
||||||
|
export const uploadMailAttachment = multer({
|
||||||
|
storage,
|
||||||
|
fileFilter,
|
||||||
|
limits: {
|
||||||
|
fileSize: 10 * 1024 * 1024, // 10MB 제한
|
||||||
|
files: 5, // 최대 5개 파일
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 발송 API
|
||||||
|
router.post(
|
||||||
|
'/simple',
|
||||||
|
uploadMailAttachment.array('attachments', 5),
|
||||||
|
(req, res) => mailSendSimpleController.sendMail(req, res)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**보안 기능:**
|
||||||
|
- ✅ 위험한 파일 확장자 차단 (.exe, .bat, .cmd, .sh 등)
|
||||||
|
- ✅ 파일 크기 제한 (10MB)
|
||||||
|
- ✅ 파일 개수 제한 (최대 5개)
|
||||||
|
- ✅ 안전한 파일명 생성
|
||||||
|
|
||||||
|
**프론트엔드 구현 예정 (Phase 1-3):**
|
||||||
|
- 드래그 앤 드롭 파일 업로드
|
||||||
|
- 첨부된 파일 목록 표시
|
||||||
|
- 파일 삭제 기능
|
||||||
|
- 미리보기에 첨부파일 정보 표시
|
||||||
|
|
||||||
|
#### 5. 향후 작업 계획
|
||||||
|
|
||||||
|
**Phase 2: 보낸메일함 백엔드**
|
||||||
|
- 발송 이력 자동 저장 (JSON 파일)
|
||||||
|
- 발송 상태 관리 (성공/실패)
|
||||||
|
- 발송 이력 조회 API
|
||||||
|
|
||||||
|
**Phase 3: 보낸메일함 프론트엔드**
|
||||||
|
- `/admin/mail/sent` 페이지
|
||||||
|
- 발송 목록 테이블
|
||||||
|
- 상세보기 모달
|
||||||
|
- 재전송 기능
|
||||||
|
|
||||||
|
**Phase 4: 대시보드 통합**
|
||||||
|
- 대시보드에 "보낸메일함" 링크
|
||||||
|
- 실제 발송 통계 연동
|
||||||
|
- 최근 활동 목록
|
||||||
|
|
||||||
|
### 메일 시스템 UI 가이드
|
||||||
|
|
||||||
|
#### 이메일 태그 입력
|
||||||
|
```tsx
|
||||||
|
// 이메일 주소를 시각적으로 표시
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 bg-blue-100 text-blue-700 rounded-md text-sm"
|
||||||
|
>
|
||||||
|
<Mail className="w-3 h-3" />
|
||||||
|
{tag}
|
||||||
|
<button onClick={() => removeTag(index)}>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 파일 첨부 영역 (예정)
|
||||||
|
```tsx
|
||||||
|
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 hover:border-orange-400 transition-colors">
|
||||||
|
<input type="file" multiple className="hidden" />
|
||||||
|
<div className="text-center">
|
||||||
|
<Upload className="w-12 h-12 mx-auto text-gray-400" />
|
||||||
|
<p className="mt-2 text-sm text-gray-600">
|
||||||
|
파일을 드래그하거나 클릭하여 선택하세요
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
최대 5개, 각 10MB 이하
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 발송 성공 토스트
|
||||||
|
```tsx
|
||||||
|
<div className="fixed top-4 right-4 bg-white rounded-lg shadow-lg border border-green-200 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">메일이 발송되었습니다</p>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{to.length}명에게 전송 완료
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
**이 가이드를 따라 개발하면 일관되고 아름다운 UI를 만들 수 있습니다!** 🎨✨
|
**이 가이드를 따라 개발하면 일관되고 아름다운 UI를 만들 수 있습니다!** 🎨✨
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
{
|
||||||
|
"id": "2d848b19-26e1-45ad-8e2c-9205f1f01c87",
|
||||||
|
"sentAt": "2025-10-02T07:50:25.817Z",
|
||||||
|
"accountId": "account-1759310844272",
|
||||||
|
"accountName": "이희진",
|
||||||
|
"accountEmail": "hjlee@wace.me",
|
||||||
|
"to": [
|
||||||
|
"zian9227@naver.com"
|
||||||
|
],
|
||||||
|
"subject": "ㅣ;ㅏㅓ",
|
||||||
|
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅓㅏㅣ</p>\r\n </div>\r\n ",
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"filename": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
||||||
|
"originalName": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
||||||
|
"size": 0,
|
||||||
|
"path": "/app/uploads/mail-attachments/1759391422625-269479520_test____________________________33.jpg",
|
||||||
|
"mimetype": "image/jpeg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
||||||
|
"originalName": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
||||||
|
"size": 0,
|
||||||
|
"path": "/app/uploads/mail-attachments/1759391422626-68453569_UI_______________________________________________.md",
|
||||||
|
"mimetype": "text/x-markdown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "testáá
á¼ áá
栠
栠
µ2.png",
|
||||||
|
"originalName": "testáá
á¼ áá
栠
栠
µ2.png",
|
||||||
|
"size": 0,
|
||||||
|
"path": "/app/uploads/mail-attachments/1759391422626-168170034_test____________________________2.png",
|
||||||
|
"mimetype": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"status": "success",
|
||||||
|
"messageId": "<9d5b8275-e059-3a71-a34a-dea800730aa3@wace.me>",
|
||||||
|
"accepted": [
|
||||||
|
"zian9227@naver.com"
|
||||||
|
],
|
||||||
|
"rejected": []
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
{
|
||||||
|
"id": "37fce6a0-2301-431b-b573-82bdab9b8008",
|
||||||
|
"sentAt": "2025-10-02T07:44:38.128Z",
|
||||||
|
"accountId": "account-1759310844272",
|
||||||
|
"accountName": "이희진",
|
||||||
|
"accountEmail": "hjlee@wace.me",
|
||||||
|
"to": [
|
||||||
|
"zian9227@naver.com"
|
||||||
|
],
|
||||||
|
"subject": "asd",
|
||||||
|
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">asd</p>\r\n </div>\r\n ",
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"filename": "áá
°áá
栠
³-áá
µá·áá
µá¨áá
¯á«-áá
³á
á
©áá
¡áá
µá¯-áá
栠
´áá
栮.key",
|
||||||
|
"originalName": "áá
°áá
栠
³-áá
µá·áá
µá¨áá
¯á«-áá
³á
á
©áá
¡áá
µá¯-áá
栠
´áá
栮.key",
|
||||||
|
"size": 0,
|
||||||
|
"path": "/app/uploads/mail-attachments/1759391076653-58189058___________________-___________________________-___________________________-_____________________.key",
|
||||||
|
"mimetype": "application/x-iwork-keynote-sffkey"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "áá
°áá
栠
³-áá
µá·áá
µá¨áá
¯á«-áá
³á
á
©áá
¡áá
µá¯-áá
栠
´áá
栮.pptx",
|
||||||
|
"originalName": "áá
°áá
栠
³-áá
µá·áá
µá¨áá
¯á«-áá
³á
á
©áá
¡áá
µá¯-áá
栠
´áá
栮.pptx",
|
||||||
|
"size": 0,
|
||||||
|
"path": "/app/uploads/mail-attachments/1759391076736-190208246___________________-___________________________-___________________________-_____________________.pptx",
|
||||||
|
"mimetype": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
||||||
|
"originalName": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
||||||
|
"size": 0,
|
||||||
|
"path": "/app/uploads/mail-attachments/1759391076738-240665795_test____________________________33.jpg",
|
||||||
|
"mimetype": "image/jpeg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"status": "success",
|
||||||
|
"messageId": "<796cb9a7-df62-31c4-ae6b-b42f383d82b4@wace.me>",
|
||||||
|
"accepted": [
|
||||||
|
"zian9227@naver.com"
|
||||||
|
],
|
||||||
|
"rejected": []
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
{
|
||||||
|
"id": "a1ca39ad-4467-44e0-963a-fba5037c8896",
|
||||||
|
"sentAt": "2025-10-02T08:22:14.721Z",
|
||||||
|
"accountId": "account-1759310844272",
|
||||||
|
"accountName": "이희진",
|
||||||
|
"accountEmail": "hjlee@wace.me",
|
||||||
|
"to": [
|
||||||
|
"zian9227@naver.com"
|
||||||
|
],
|
||||||
|
"subject": "ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴ",
|
||||||
|
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㄴㅁㅇㄹ</p>\r\n </div>\r\n ",
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"filename": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
||||||
|
"originalName": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
||||||
|
"size": 0,
|
||||||
|
"path": "/app/uploads/mail-attachments/1759393332207-791945862_test____________________________33.jpg",
|
||||||
|
"mimetype": "image/jpeg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
||||||
|
"originalName": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
||||||
|
"size": 0,
|
||||||
|
"path": "/app/uploads/mail-attachments/1759393332208-660280542_UI_______________________________________________.md",
|
||||||
|
"mimetype": "text/x-markdown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "testáá
á¼ áá
栠
栠
µ2.png",
|
||||||
|
"originalName": "testáá
á¼ áá
栠
栠
µ2.png",
|
||||||
|
"size": 0,
|
||||||
|
"path": "/app/uploads/mail-attachments/1759393332208-149486455_test____________________________2.png",
|
||||||
|
"mimetype": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"status": "success",
|
||||||
|
"messageId": "<d52bab7c-4285-8a27-12ed-b501ff858d23@wace.me>",
|
||||||
|
"accepted": [
|
||||||
|
"zian9227@naver.com"
|
||||||
|
],
|
||||||
|
"rejected": []
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
{
|
||||||
|
"id": "a3a9aab1-4334-46bd-bf50-b867305f66c0",
|
||||||
|
"sentAt": "2025-10-02T08:41:42.086Z",
|
||||||
|
"accountId": "account-1759310844272",
|
||||||
|
"accountName": "이희진",
|
||||||
|
"accountEmail": "hjlee@wace.me",
|
||||||
|
"to": [
|
||||||
|
"zian9227@naver.com"
|
||||||
|
],
|
||||||
|
"subject": "한글테스트",
|
||||||
|
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"filename": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
||||||
|
"originalName": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
||||||
|
"size": 0,
|
||||||
|
"path": "/app/uploads/mail-attachments/1759394500462-50127394_UI_______________________________________________.md",
|
||||||
|
"mimetype": "text/x-markdown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
||||||
|
"originalName": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
||||||
|
"size": 0,
|
||||||
|
"path": "/app/uploads/mail-attachments/1759394500463-68744474_test____________________________33.jpg",
|
||||||
|
"mimetype": "image/jpeg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "testáá
á¼ áá
栠
栠
µ2.png",
|
||||||
|
"originalName": "testáá
á¼ áá
栠
栠
µ2.png",
|
||||||
|
"size": 0,
|
||||||
|
"path": "/app/uploads/mail-attachments/1759394500463-464487722_test____________________________2.png",
|
||||||
|
"mimetype": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"status": "success",
|
||||||
|
"messageId": "<2dbfbf64-69c2-a83d-6bb7-515e4e654628@wace.me>",
|
||||||
|
"accepted": [
|
||||||
|
"zian9227@naver.com"
|
||||||
|
],
|
||||||
|
"rejected": []
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
{
|
||||||
|
"id": "b1d8f458-076c-4c44-982e-d2f46dcd4b03",
|
||||||
|
"sentAt": "2025-10-02T08:57:48.412Z",
|
||||||
|
"accountId": "account-1759310844272",
|
||||||
|
"accountName": "이희진",
|
||||||
|
"accountEmail": "hjlee@wace.me",
|
||||||
|
"to": [
|
||||||
|
"zian9227@naver.com"
|
||||||
|
],
|
||||||
|
"subject": "ㅁㄴㅇㄹ",
|
||||||
|
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"filename": "웨이스-임직원-프로파일-이희진.key",
|
||||||
|
"originalName": "웨이스-임직원-프로파일-이희진.key",
|
||||||
|
"size": 0,
|
||||||
|
"path": "/app/uploads/mail-attachments/1759395465488-120933172.key",
|
||||||
|
"mimetype": "application/x-iwork-keynote-sffkey"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "UI_개선사항_문서.md",
|
||||||
|
"originalName": "UI_개선사항_문서.md",
|
||||||
|
"size": 0,
|
||||||
|
"path": "/app/uploads/mail-attachments/1759395465566-306126854.md",
|
||||||
|
"mimetype": "text/x-markdown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "test용 이미지33.jpg",
|
||||||
|
"originalName": "test용 이미지33.jpg",
|
||||||
|
"size": 0,
|
||||||
|
"path": "/app/uploads/mail-attachments/1759395465566-412984398.jpg",
|
||||||
|
"mimetype": "image/jpeg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "test용 이미지2.png",
|
||||||
|
"originalName": "test용 이미지2.png",
|
||||||
|
"size": 0,
|
||||||
|
"path": "/app/uploads/mail-attachments/1759395465567-143883587.png",
|
||||||
|
"mimetype": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"status": "success",
|
||||||
|
"messageId": "<e2796753-a1a9-fbac-c035-00341e29031c@wace.me>",
|
||||||
|
"accepted": [
|
||||||
|
"zian9227@naver.com"
|
||||||
|
],
|
||||||
|
"rejected": []
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
{
|
||||||
|
"id": "b75d0b2b-7d8a-461b-b854-2bebdef959e8",
|
||||||
|
"sentAt": "2025-10-02T08:49:30.356Z",
|
||||||
|
"accountId": "account-1759310844272",
|
||||||
|
"accountName": "이희진",
|
||||||
|
"accountEmail": "hjlee@wace.me",
|
||||||
|
"to": [
|
||||||
|
"zian9227@naver.com"
|
||||||
|
],
|
||||||
|
"subject": "한글2",
|
||||||
|
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
||||||
|
"originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
||||||
|
"size": 0,
|
||||||
|
"path": "/app/uploads/mail-attachments/1759394969516-74008147_UI__________________________.md",
|
||||||
|
"mimetype": "text/x-markdown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "testááá¼ ááµááµááµ33.jpg",
|
||||||
|
"originalName": "testááá¼ ááµááµááµ33.jpg",
|
||||||
|
"size": 0,
|
||||||
|
"path": "/app/uploads/mail-attachments/1759394969516-530544653_test_______________33.jpg",
|
||||||
|
"mimetype": "image/jpeg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "testááá¼ ááµááµááµ2.png",
|
||||||
|
"originalName": "testááá¼ ááµááµááµ2.png",
|
||||||
|
"size": 0,
|
||||||
|
"path": "/app/uploads/mail-attachments/1759394969517-260831218_test_______________2.png",
|
||||||
|
"mimetype": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"status": "success",
|
||||||
|
"messageId": "<80a431a1-bb4d-31b5-2564-93f8c2539fd4@wace.me>",
|
||||||
|
"accepted": [
|
||||||
|
"zian9227@naver.com"
|
||||||
|
],
|
||||||
|
"rejected": []
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
{
|
||||||
|
"id": "ccdd8961-1b3f-4b88-b838-51d6ed8f1601",
|
||||||
|
"sentAt": "2025-10-02T08:47:03.481Z",
|
||||||
|
"accountId": "account-1759310844272",
|
||||||
|
"accountName": "이희진",
|
||||||
|
"accountEmail": "hjlee@wace.me",
|
||||||
|
"to": [
|
||||||
|
"zian9227@naver.com"
|
||||||
|
],
|
||||||
|
"subject": "한글테스트222",
|
||||||
|
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">2</p>\r\n </div>\r\n ",
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
||||||
|
"originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
||||||
|
"size": 0,
|
||||||
|
"path": "/app/uploads/mail-attachments/1759394821751-229305880_UI__________________________.md",
|
||||||
|
"mimetype": "text/x-markdown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "testááá¼ ááµááµááµ33.jpg",
|
||||||
|
"originalName": "testááá¼ ááµááµááµ33.jpg",
|
||||||
|
"size": 0,
|
||||||
|
"path": "/app/uploads/mail-attachments/1759394821751-335146895_test_______________33.jpg",
|
||||||
|
"mimetype": "image/jpeg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "testááá¼ ááµááµááµ2.png",
|
||||||
|
"originalName": "testááá¼ ááµááµááµ2.png",
|
||||||
|
"size": 0,
|
||||||
|
"path": "/app/uploads/mail-attachments/1759394821753-911076131_test_______________2.png",
|
||||||
|
"mimetype": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"status": "success",
|
||||||
|
"messageId": "<69519c70-a5cd-421d-9976-8c7014d69b39@wace.me>",
|
||||||
|
"accepted": [
|
||||||
|
"zian9227@naver.com"
|
||||||
|
],
|
||||||
|
"rejected": []
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
{
|
||||||
|
"id": "ee0d162c-48ad-4c00-8c56-ade80be4503f",
|
||||||
|
"sentAt": "2025-10-02T08:48:29.740Z",
|
||||||
|
"accountId": "account-1759310844272",
|
||||||
|
"accountName": "이희진",
|
||||||
|
"accountEmail": "hjlee@wace.me",
|
||||||
|
"to": [
|
||||||
|
"zian9227@naver.com"
|
||||||
|
],
|
||||||
|
"subject": "한글한글",
|
||||||
|
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
||||||
|
"originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
||||||
|
"size": 0,
|
||||||
|
"path": "/app/uploads/mail-attachments/1759394908877-38147683_UI__________________________.md",
|
||||||
|
"mimetype": "text/x-markdown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "testááá¼ ááµááµááµ33.jpg",
|
||||||
|
"originalName": "testááá¼ ááµááµááµ33.jpg",
|
||||||
|
"size": 0,
|
||||||
|
"path": "/app/uploads/mail-attachments/1759394908879-80461065_test_______________33.jpg",
|
||||||
|
"mimetype": "image/jpeg"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "testááá¼ ááµááµááµ2.png",
|
||||||
|
"originalName": "testááá¼ ááµááµááµ2.png",
|
||||||
|
"size": 0,
|
||||||
|
"path": "/app/uploads/mail-attachments/1759394908880-475630926_test_______________2.png",
|
||||||
|
"mimetype": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"status": "success",
|
||||||
|
"messageId": "<96205714-1a6b-adb7-7ae5-0e1e3fcb700b@wace.me>",
|
||||||
|
"accepted": [
|
||||||
|
"zian9227@naver.com"
|
||||||
|
],
|
||||||
|
"rejected": []
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^7.1.5",
|
"express-rate-limit": "^7.1.5",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
|
"iconv-lite": "^0.7.0",
|
||||||
"imap": "^0.8.19",
|
"imap": "^0.8.19",
|
||||||
"joi": "^17.11.0",
|
"joi": "^17.11.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
|
@ -4237,6 +4238,18 @@
|
||||||
"ms": "2.0.0"
|
"ms": "2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/body-parser/node_modules/iconv-lite": {
|
||||||
|
"version": "0.4.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||||
|
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/body-parser/node_modules/ms": {
|
"node_modules/body-parser/node_modules/ms": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
|
|
@ -6365,15 +6378,19 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.4.24",
|
"version": "0.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
|
||||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safer-buffer": ">= 2.1.2 < 3"
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ieee754": {
|
"node_modules/ieee754": {
|
||||||
|
|
@ -8026,22 +8043,6 @@
|
||||||
"node": ">= 8.0"
|
"node": ">= 8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mysql2/node_modules/iconv-lite": {
|
|
||||||
"version": "0.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
|
|
||||||
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/express"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/named-placeholders": {
|
"node_modules/named-placeholders": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz",
|
||||||
|
|
@ -8936,6 +8937,18 @@
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/raw-body/node_modules/iconv-lite": {
|
||||||
|
"version": "0.4.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||||
|
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^7.1.5",
|
"express-rate-limit": "^7.1.5",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
|
"iconv-lite": "^0.7.0",
|
||||||
"imap": "^0.8.19",
|
"imap": "^0.8.19",
|
||||||
"joi": "^17.11.0",
|
"joi": "^17.11.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import mailTemplateFileRoutes from "./routes/mailTemplateFileRoutes";
|
||||||
import mailAccountFileRoutes from "./routes/mailAccountFileRoutes";
|
import mailAccountFileRoutes from "./routes/mailAccountFileRoutes";
|
||||||
import mailSendSimpleRoutes from "./routes/mailSendSimpleRoutes";
|
import mailSendSimpleRoutes from "./routes/mailSendSimpleRoutes";
|
||||||
import mailReceiveBasicRoutes from "./routes/mailReceiveBasicRoutes";
|
import mailReceiveBasicRoutes from "./routes/mailReceiveBasicRoutes";
|
||||||
|
import mailSentHistoryRoutes from "./routes/mailSentHistoryRoutes";
|
||||||
import dataRoutes from "./routes/dataRoutes";
|
import dataRoutes from "./routes/dataRoutes";
|
||||||
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
||||||
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
||||||
|
|
@ -165,6 +166,7 @@ app.use("/api/mail/accounts", mailAccountFileRoutes); // 파일 기반 계정
|
||||||
app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿
|
app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿
|
||||||
app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송
|
app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송
|
||||||
app.use("/api/mail/receive", mailReceiveBasicRoutes); // 메일 수신
|
app.use("/api/mail/receive", mailReceiveBasicRoutes); // 메일 수신
|
||||||
|
app.use("/api/mail/sent", mailSentHistoryRoutes); // 발송 이력
|
||||||
app.use("/api/screen", screenStandardRoutes);
|
app.use("/api/screen", screenStandardRoutes);
|
||||||
app.use("/api/data", dataRoutes);
|
app.use("/api/data", dataRoutes);
|
||||||
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
import multer from 'multer';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
// 업로드 디렉토리 경로
|
||||||
|
const UPLOAD_DIR = path.join(__dirname, '../../uploads/mail-attachments');
|
||||||
|
|
||||||
|
// 디렉토리 생성 (없으면)
|
||||||
|
if (!fs.existsSync(UPLOAD_DIR)) {
|
||||||
|
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 간단한 파일명 정규화 함수 (한글-분석.txt 방식)
|
||||||
|
function normalizeFileName(filename: string): string {
|
||||||
|
if (!filename) return filename;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// NFC 정규화만 수행 (복잡한 디코딩 제거)
|
||||||
|
return filename.normalize('NFC');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to normalize filename: ${filename}`, error);
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 저장 설정
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
cb(null, UPLOAD_DIR);
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
try {
|
||||||
|
// 파일명 정규화 (한글-분석.txt 방식)
|
||||||
|
file.originalname = file.originalname.normalize('NFC');
|
||||||
|
|
||||||
|
console.log('File upload - Processing:', {
|
||||||
|
original: file.originalname,
|
||||||
|
originalHex: Buffer.from(file.originalname).toString('hex'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// UUID + 확장자로 유니크한 파일명 생성
|
||||||
|
const uniqueId = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||||
|
const ext = path.extname(file.originalname);
|
||||||
|
const filename = `${uniqueId}${ext}`;
|
||||||
|
|
||||||
|
console.log('Generated filename:', {
|
||||||
|
original: file.originalname,
|
||||||
|
generated: filename,
|
||||||
|
});
|
||||||
|
|
||||||
|
cb(null, filename);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Filename processing error:', error);
|
||||||
|
const fallbackFilename = `${Date.now()}-${Math.round(Math.random() * 1e9)}_error.tmp`;
|
||||||
|
cb(null, fallbackFilename);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 파일 필터 (허용할 파일 타입)
|
||||||
|
const fileFilter = (req: any, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
|
||||||
|
// 파일명 정규화 (fileFilter가 filename보다 먼저 실행되므로 여기서 먼저 처리)
|
||||||
|
try {
|
||||||
|
// NFD를 NFC로 정규화만 수행
|
||||||
|
file.originalname = file.originalname.normalize('NFC');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to normalize filename in fileFilter:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 위험한 파일 확장자 차단
|
||||||
|
const dangerousExtensions = ['.exe', '.bat', '.cmd', '.sh', '.ps1', '.msi'];
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase();
|
||||||
|
|
||||||
|
if (dangerousExtensions.includes(ext)) {
|
||||||
|
console.log(`❌ 차단된 파일 타입: ${ext}`);
|
||||||
|
cb(new Error(`보안상의 이유로 ${ext} 파일은 첨부할 수 없습니다.`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cb(null, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Multer 설정
|
||||||
|
export const uploadMailAttachment = multer({
|
||||||
|
storage,
|
||||||
|
fileFilter,
|
||||||
|
limits: {
|
||||||
|
fileSize: 10 * 1024 * 1024, // 10MB 제한
|
||||||
|
files: 5, // 최대 5개 파일
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 첨부파일 정보 추출 헬퍼
|
||||||
|
export interface AttachmentInfo {
|
||||||
|
filename: string;
|
||||||
|
originalName: string;
|
||||||
|
size: number;
|
||||||
|
path: string;
|
||||||
|
mimetype: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const extractAttachmentInfo = (files: Express.Multer.File[]): AttachmentInfo[] => {
|
||||||
|
return files.map((file) => ({
|
||||||
|
filename: file.filename,
|
||||||
|
originalName: file.originalname,
|
||||||
|
size: file.size,
|
||||||
|
path: file.path,
|
||||||
|
mimetype: file.mimetype,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -3,12 +3,28 @@ import { mailSendSimpleService } from '../services/mailSendSimpleService';
|
||||||
|
|
||||||
export class MailSendSimpleController {
|
export class MailSendSimpleController {
|
||||||
/**
|
/**
|
||||||
* 메일 발송 (단건 또는 소규모)
|
* 메일 발송 (단건 또는 소규모) - 첨부파일 지원
|
||||||
*/
|
*/
|
||||||
async sendMail(req: Request, res: Response) {
|
async sendMail(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
console.log('📧 메일 발송 요청 수신:', { accountId: req.body.accountId, to: req.body.to, subject: req.body.subject });
|
console.log('📧 메일 발송 요청 수신:', {
|
||||||
const { accountId, templateId, to, subject, variables, customHtml } = req.body;
|
accountId: req.body.accountId,
|
||||||
|
to: req.body.to,
|
||||||
|
cc: req.body.cc,
|
||||||
|
bcc: req.body.bcc,
|
||||||
|
subject: req.body.subject,
|
||||||
|
attachments: req.files ? (req.files as Express.Multer.File[]).length : 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// FormData에서 JSON 문자열 파싱
|
||||||
|
const accountId = req.body.accountId;
|
||||||
|
const templateId = req.body.templateId;
|
||||||
|
const to = req.body.to ? JSON.parse(req.body.to) : [];
|
||||||
|
const cc = req.body.cc ? JSON.parse(req.body.cc) : undefined;
|
||||||
|
const bcc = req.body.bcc ? JSON.parse(req.body.bcc) : undefined;
|
||||||
|
const subject = req.body.subject;
|
||||||
|
const variables = req.body.variables ? JSON.parse(req.body.variables) : undefined;
|
||||||
|
const customHtml = req.body.customHtml;
|
||||||
|
|
||||||
// 필수 파라미터 검증
|
// 필수 파라미터 검증
|
||||||
if (!accountId || !to || !Array.isArray(to) || to.length === 0) {
|
if (!accountId || !to || !Array.isArray(to) || to.length === 0) {
|
||||||
|
|
@ -34,14 +50,53 @@ export class MailSendSimpleController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 첨부파일 처리 (한글 파일명 지원)
|
||||||
|
const attachments: Array<{ filename: string; path: string; contentType?: string }> = [];
|
||||||
|
if (req.files && Array.isArray(req.files)) {
|
||||||
|
const files = req.files as Express.Multer.File[];
|
||||||
|
|
||||||
|
// 프론트엔드에서 전송한 정규화된 파일명 사용 (한글-분석.txt 방식)
|
||||||
|
let parsedFileNames: string[] = [];
|
||||||
|
if (req.body.fileNames) {
|
||||||
|
try {
|
||||||
|
parsedFileNames = JSON.parse(req.body.fileNames);
|
||||||
|
console.log('📎 프론트엔드에서 받은 파일명들:', parsedFileNames);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('파일명 파싱 실패, multer originalname 사용');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
files.forEach((file, index) => {
|
||||||
|
// 클라이언트에서 전송한 파일명 우선 사용, 없으면 multer의 originalname 사용
|
||||||
|
let originalName = parsedFileNames[index] || file.originalname;
|
||||||
|
|
||||||
|
// NFC 정규화 확실히 수행
|
||||||
|
originalName = originalName.normalize('NFC');
|
||||||
|
|
||||||
|
attachments.push({
|
||||||
|
filename: originalName,
|
||||||
|
path: file.path,
|
||||||
|
contentType: file.mimetype,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('📎 최종 첨부파일 정보:', attachments.map(a => ({
|
||||||
|
filename: a.filename,
|
||||||
|
path: a.path.split('/').pop()
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
// 메일 발송
|
// 메일 발송
|
||||||
const result = await mailSendSimpleService.sendMail({
|
const result = await mailSendSimpleService.sendMail({
|
||||||
accountId,
|
accountId,
|
||||||
templateId,
|
templateId,
|
||||||
to,
|
to,
|
||||||
|
cc,
|
||||||
|
bcc,
|
||||||
subject,
|
subject,
|
||||||
variables,
|
variables,
|
||||||
customHtml,
|
customHtml,
|
||||||
|
attachments: attachments.length > 0 ? attachments : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { mailSentHistoryService } from '../services/mailSentHistoryService';
|
||||||
|
|
||||||
|
export class MailSentHistoryController {
|
||||||
|
/**
|
||||||
|
* 발송 이력 목록 조회
|
||||||
|
*/
|
||||||
|
async getList(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const query = {
|
||||||
|
page: req.query.page ? parseInt(req.query.page as string) : undefined,
|
||||||
|
limit: req.query.limit ? parseInt(req.query.limit as string) : undefined,
|
||||||
|
searchTerm: req.query.searchTerm as string | undefined,
|
||||||
|
status: req.query.status as 'success' | 'failed' | 'all' | undefined,
|
||||||
|
accountId: req.query.accountId as string | undefined,
|
||||||
|
startDate: req.query.startDate as string | undefined,
|
||||||
|
endDate: req.query.endDate as string | undefined,
|
||||||
|
sortBy: req.query.sortBy as 'sentAt' | 'subject' | undefined,
|
||||||
|
sortOrder: req.query.sortOrder as 'asc' | 'desc' | undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await mailSentHistoryService.getSentMailList(query);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
console.error('발송 이력 목록 조회 실패:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '발송 이력 조회 중 오류가 발생했습니다.',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 발송 이력 상세 조회
|
||||||
|
*/
|
||||||
|
async getById(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '발송 이력 ID가 필요합니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = await mailSentHistoryService.getSentMailById(id);
|
||||||
|
|
||||||
|
if (!history) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '발송 이력을 찾을 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: history,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
console.error('발송 이력 조회 실패:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '발송 이력 조회 중 오류가 발생했습니다.',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 발송 이력 삭제
|
||||||
|
*/
|
||||||
|
async deleteById(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '발송 이력 ID가 필요합니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await mailSentHistoryService.deleteSentMail(id);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '발송 이력을 찾을 수 없습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: '발송 이력이 삭제되었습니다.',
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
console.error('발송 이력 삭제 실패:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '발송 이력 삭제 중 오류가 발생했습니다.',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 통계 조회
|
||||||
|
*/
|
||||||
|
async getStatistics(req: Request, res: Response) {
|
||||||
|
try {
|
||||||
|
const accountId = req.query.accountId as string | undefined;
|
||||||
|
const stats = await mailSentHistoryService.getStatistics(accountId);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: stats,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
console.error('통계 조회 실패:', err);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '통계 조회 중 오류가 발생했습니다.',
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mailSentHistoryController = new MailSentHistoryController();
|
||||||
|
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { mailSendSimpleController } from '../controllers/mailSendSimpleController';
|
import { mailSendSimpleController } from '../controllers/mailSendSimpleController';
|
||||||
import { authenticateToken } from '../middleware/authMiddleware';
|
import { authenticateToken } from '../middleware/authMiddleware';
|
||||||
|
import { uploadMailAttachment } from '../config/multerConfig';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// 모든 메일 발송 라우트에 인증 미들웨어 적용
|
// 모든 메일 발송 라우트에 인증 미들웨어 적용
|
||||||
router.use(authenticateToken);
|
router.use(authenticateToken);
|
||||||
|
|
||||||
// POST /api/mail/send/simple - 메일 발송
|
// POST /api/mail/send/simple - 메일 발송 (첨부파일 지원)
|
||||||
router.post('/simple', (req, res) => mailSendSimpleController.sendMail(req, res));
|
router.post(
|
||||||
|
'/simple',
|
||||||
|
uploadMailAttachment.array('attachments', 5), // 최대 5개 파일
|
||||||
|
(req, res) => mailSendSimpleController.sendMail(req, res)
|
||||||
|
);
|
||||||
|
|
||||||
// POST /api/mail/send/test-connection - SMTP 연결 테스트
|
// POST /api/mail/send/test-connection - SMTP 연결 테스트
|
||||||
router.post('/test-connection', (req, res) => mailSendSimpleController.testConnection(req, res));
|
router.post('/test-connection', (req, res) => mailSendSimpleController.testConnection(req, res));
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { mailSentHistoryController } from '../controllers/mailSentHistoryController';
|
||||||
|
import { authenticateToken } from '../middleware/authMiddleware';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 모든 라우트에 인증 미들웨어 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// GET /api/mail/sent - 발송 이력 목록 조회
|
||||||
|
router.get('/', (req, res) => mailSentHistoryController.getList(req, res));
|
||||||
|
|
||||||
|
// GET /api/mail/sent/statistics - 통계 조회
|
||||||
|
router.get('/statistics', (req, res) => mailSentHistoryController.getStatistics(req, res));
|
||||||
|
|
||||||
|
// GET /api/mail/sent/:id - 특정 발송 이력 상세 조회
|
||||||
|
router.get('/:id', (req, res) => mailSentHistoryController.getById(req, res));
|
||||||
|
|
||||||
|
// DELETE /api/mail/sent/:id - 발송 이력 삭제
|
||||||
|
router.delete('/:id', (req, res) => mailSentHistoryController.deleteById(req, res));
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
|
|
@ -3,9 +3,11 @@
|
||||||
* IMAP 연결 및 메일 목록 조회
|
* IMAP 연결 및 메일 목록 조회
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as Imap from 'imap';
|
// CommonJS 모듈이므로 require 사용
|
||||||
|
const Imap = require('imap');
|
||||||
import { simpleParser } from 'mailparser';
|
import { simpleParser } from 'mailparser';
|
||||||
import { mailAccountFileService } from './mailAccountFileService';
|
import { mailAccountFileService } from './mailAccountFileService';
|
||||||
|
import { encryptionService } from './encryptionService';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
|
@ -57,6 +59,20 @@ export class MailReceiveBasicService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SMTP 포트에서 IMAP 포트 추론
|
||||||
|
*/
|
||||||
|
private inferImapPort(smtpPort: number, imapPort?: number): number {
|
||||||
|
if (imapPort) return imapPort;
|
||||||
|
|
||||||
|
if (smtpPort === 465 || smtpPort === 587) {
|
||||||
|
return 993; // IMAPS (SSL/TLS)
|
||||||
|
} else if (smtpPort === 25) {
|
||||||
|
return 143; // IMAP (no encryption)
|
||||||
|
}
|
||||||
|
return 993; // 기본값: IMAPS
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IMAP 연결 생성
|
* IMAP 연결 생성
|
||||||
*/
|
*/
|
||||||
|
|
@ -80,27 +96,47 @@ export class MailReceiveBasicService {
|
||||||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 비밀번호 복호화
|
||||||
|
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||||
|
|
||||||
|
// IMAP 설정
|
||||||
|
const accountAny = account as any;
|
||||||
const imapConfig: ImapConfig = {
|
const imapConfig: ImapConfig = {
|
||||||
user: account.email,
|
user: account.email,
|
||||||
password: account.smtpPassword, // 이미 복호화됨
|
password: decryptedPassword,
|
||||||
host: account.smtpHost,
|
host: accountAny.imapHost || account.smtpHost,
|
||||||
port: account.smtpPort === 587 ? 993 : account.smtpPort, // SMTP 587 -> IMAP 993
|
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
|
||||||
tls: true,
|
tls: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log(`📧 IMAP 연결 시도 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, 이메일: ${imapConfig.user}`);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const imap = this.createImapConnection(imapConfig);
|
const imap = this.createImapConnection(imapConfig);
|
||||||
const mails: ReceivedMail[] = [];
|
const mails: ReceivedMail[] = [];
|
||||||
|
|
||||||
|
// 30초 타임아웃 설정
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
console.error('❌ IMAP 연결 타임아웃 (30초)');
|
||||||
|
imap.end();
|
||||||
|
reject(new Error('IMAP 연결 타임아웃'));
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
imap.once('ready', () => {
|
imap.once('ready', () => {
|
||||||
|
console.log('✅ IMAP 연결 성공! INBOX 열기 시도...');
|
||||||
|
clearTimeout(timeout);
|
||||||
|
|
||||||
imap.openBox('INBOX', true, (err: any, box: any) => {
|
imap.openBox('INBOX', true, (err: any, box: any) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
console.error('❌ INBOX 열기 실패:', err);
|
||||||
imap.end();
|
imap.end();
|
||||||
return reject(err);
|
return reject(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`📬 INBOX 열림 - 전체 메일 수: ${box.messages.total}`);
|
||||||
const totalMessages = box.messages.total;
|
const totalMessages = box.messages.total;
|
||||||
if (totalMessages === 0) {
|
if (totalMessages === 0) {
|
||||||
|
console.log('📭 메일함이 비어있습니다');
|
||||||
imap.end();
|
imap.end();
|
||||||
return resolve([]);
|
return resolve([]);
|
||||||
}
|
}
|
||||||
|
|
@ -109,15 +145,23 @@ export class MailReceiveBasicService {
|
||||||
const start = Math.max(1, totalMessages - limit + 1);
|
const start = Math.max(1, totalMessages - limit + 1);
|
||||||
const end = totalMessages;
|
const end = totalMessages;
|
||||||
|
|
||||||
|
console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`);
|
||||||
const fetch = imap.seq.fetch(`${start}:${end}`, {
|
const fetch = imap.seq.fetch(`${start}:${end}`, {
|
||||||
bodies: ['HEADER', 'TEXT'],
|
bodies: ['HEADER', 'TEXT'],
|
||||||
struct: true,
|
struct: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(`📦 fetch 객체 생성 완료`);
|
||||||
|
|
||||||
|
let processedCount = 0;
|
||||||
|
const totalToProcess = end - start + 1;
|
||||||
|
|
||||||
fetch.on('message', (msg: any, seqno: any) => {
|
fetch.on('message', (msg: any, seqno: any) => {
|
||||||
|
console.log(`📬 메일 #${seqno} 처리 시작`);
|
||||||
let header: string = '';
|
let header: string = '';
|
||||||
let body: string = '';
|
let body: string = '';
|
||||||
let attributes: any = null;
|
let attributes: any = null;
|
||||||
|
let bodiesReceived = 0;
|
||||||
|
|
||||||
msg.on('body', (stream: any, info: any) => {
|
msg.on('body', (stream: any, info: any) => {
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
|
|
@ -130,6 +174,7 @@ export class MailReceiveBasicService {
|
||||||
} else {
|
} else {
|
||||||
body = buffer;
|
body = buffer;
|
||||||
}
|
}
|
||||||
|
bodiesReceived++;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -137,50 +182,88 @@ export class MailReceiveBasicService {
|
||||||
attributes = attrs;
|
attributes = attrs;
|
||||||
});
|
});
|
||||||
|
|
||||||
msg.once('end', async () => {
|
msg.once('end', () => {
|
||||||
try {
|
// body 데이터를 모두 받을 때까지 대기
|
||||||
const parsed = await simpleParser(header + '\r\n\r\n' + body);
|
const waitForBodies = setInterval(async () => {
|
||||||
|
if (bodiesReceived >= 2 || (header && body)) {
|
||||||
|
clearInterval(waitForBodies);
|
||||||
|
|
||||||
const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from;
|
try {
|
||||||
const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to;
|
const parsed = await simpleParser(header + '\r\n\r\n' + body);
|
||||||
|
|
||||||
const mail: ReceivedMail = {
|
const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from;
|
||||||
id: `${accountId}-${seqno}`,
|
const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to;
|
||||||
messageId: parsed.messageId || `${seqno}`,
|
|
||||||
from: fromAddress?.text || 'Unknown',
|
|
||||||
to: toAddress?.text || '',
|
|
||||||
subject: parsed.subject || '(제목 없음)',
|
|
||||||
date: parsed.date || new Date(),
|
|
||||||
preview: this.extractPreview(parsed.text || parsed.html || ''),
|
|
||||||
isRead: attributes?.flags?.includes('\\Seen') || false,
|
|
||||||
hasAttachments: (parsed.attachments?.length || 0) > 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
mails.push(mail);
|
const mail: ReceivedMail = {
|
||||||
} catch (parseError) {
|
id: `${accountId}-${seqno}`,
|
||||||
console.error('메일 파싱 오류:', parseError);
|
messageId: parsed.messageId || `${seqno}`,
|
||||||
}
|
from: fromAddress?.text || 'Unknown',
|
||||||
|
to: toAddress?.text || '',
|
||||||
|
subject: parsed.subject || '(제목 없음)',
|
||||||
|
date: parsed.date || new Date(),
|
||||||
|
preview: this.extractPreview(parsed.text || parsed.html || ''),
|
||||||
|
isRead: attributes?.flags?.includes('\\Seen') || false,
|
||||||
|
hasAttachments: (parsed.attachments?.length || 0) > 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
mails.push(mail);
|
||||||
|
console.log(`✓ 메일 #${seqno} 파싱 완료 (${mails.length}/${totalToProcess})`);
|
||||||
|
processedCount++;
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error(`메일 #${seqno} 파싱 오류:`, parseError);
|
||||||
|
processedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
fetch.once('error', (fetchErr: any) => {
|
fetch.once('error', (fetchErr: any) => {
|
||||||
|
console.error('❌ 메일 fetch 에러:', fetchErr);
|
||||||
imap.end();
|
imap.end();
|
||||||
reject(fetchErr);
|
reject(fetchErr);
|
||||||
});
|
});
|
||||||
|
|
||||||
fetch.once('end', () => {
|
fetch.once('end', () => {
|
||||||
imap.end();
|
console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`);
|
||||||
// 최신 메일이 위로 오도록 정렬
|
|
||||||
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
|
// 모든 메일 처리가 완료될 때까지 대기
|
||||||
resolve(mails);
|
const checkComplete = setInterval(() => {
|
||||||
|
console.log(`⏳ 대기 중 - 처리됨: ${processedCount}/${totalToProcess}, 메일: ${mails.length}개`);
|
||||||
|
if (processedCount >= totalToProcess) {
|
||||||
|
clearInterval(checkComplete);
|
||||||
|
console.log(`✅ 메일 가져오기 완료 - 총 ${mails.length}개`);
|
||||||
|
imap.end();
|
||||||
|
// 최신 메일이 위로 오도록 정렬
|
||||||
|
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||||
|
console.log(`📤 메일 목록 반환: ${mails.length}개`);
|
||||||
|
resolve(mails);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// 최대 10초 대기
|
||||||
|
setTimeout(() => {
|
||||||
|
clearInterval(checkComplete);
|
||||||
|
console.log(`⚠️ 타임아웃 - 부분 반환: ${mails.length}/${totalToProcess}개`);
|
||||||
|
imap.end();
|
||||||
|
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||||
|
resolve(mails);
|
||||||
|
}, 10000);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
imap.once('error', (imapErr: any) => {
|
imap.once('error', (imapErr: any) => {
|
||||||
|
console.error('❌ IMAP 연결 에러:', imapErr.message || imapErr);
|
||||||
|
clearTimeout(timeout);
|
||||||
reject(imapErr);
|
reject(imapErr);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
imap.once('end', () => {
|
||||||
|
console.log('🔌 IMAP 연결 종료');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('🔗 IMAP.connect() 호출...');
|
||||||
imap.connect();
|
imap.connect();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -206,11 +289,15 @@ export class MailReceiveBasicService {
|
||||||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 비밀번호 복호화
|
||||||
|
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||||
|
|
||||||
|
const accountAny = account as any;
|
||||||
const imapConfig: ImapConfig = {
|
const imapConfig: ImapConfig = {
|
||||||
user: account.email,
|
user: account.email,
|
||||||
password: account.smtpPassword,
|
password: decryptedPassword,
|
||||||
host: account.smtpHost,
|
host: accountAny.imapHost || account.smtpHost,
|
||||||
port: account.smtpPort === 587 ? 993 : account.smtpPort,
|
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
|
||||||
tls: true,
|
tls: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -302,11 +389,15 @@ export class MailReceiveBasicService {
|
||||||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 비밀번호 복호화
|
||||||
|
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||||
|
|
||||||
|
const accountAny = account as any;
|
||||||
const imapConfig: ImapConfig = {
|
const imapConfig: ImapConfig = {
|
||||||
user: account.email,
|
user: account.email,
|
||||||
password: account.smtpPassword,
|
password: decryptedPassword,
|
||||||
host: account.smtpHost,
|
host: accountAny.imapHost || account.smtpHost,
|
||||||
port: account.smtpPort === 587 ? 993 : account.smtpPort,
|
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
|
||||||
tls: true,
|
tls: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -352,13 +443,19 @@ export class MailReceiveBasicService {
|
||||||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 비밀번호 복호화
|
||||||
|
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||||
|
// console.log(`🔐 IMAP 테스트 - 이메일: ${account.email}, 비밀번호 길이: ${decryptedPassword.length}`);
|
||||||
|
|
||||||
|
const accountAny = account as any;
|
||||||
const imapConfig: ImapConfig = {
|
const imapConfig: ImapConfig = {
|
||||||
user: account.email,
|
user: account.email,
|
||||||
password: account.smtpPassword,
|
password: decryptedPassword,
|
||||||
host: account.smtpHost,
|
host: accountAny.imapHost || account.smtpHost,
|
||||||
port: account.smtpPort === 587 ? 993 : account.smtpPort,
|
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
|
||||||
tls: true,
|
tls: true,
|
||||||
};
|
};
|
||||||
|
// console.log(`📧 IMAP 설정 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, TLS: ${imapConfig.tls}`);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const imap = this.createImapConnection(imapConfig);
|
const imap = this.createImapConnection(imapConfig);
|
||||||
|
|
@ -408,11 +505,15 @@ export class MailReceiveBasicService {
|
||||||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 비밀번호 복호화
|
||||||
|
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||||
|
|
||||||
|
const accountAny = account as any;
|
||||||
const imapConfig: ImapConfig = {
|
const imapConfig: ImapConfig = {
|
||||||
user: account.email,
|
user: account.email,
|
||||||
password: account.smtpPassword,
|
password: decryptedPassword,
|
||||||
host: account.smtpHost,
|
host: accountAny.imapHost || account.smtpHost,
|
||||||
port: account.smtpPort === 587 ? 993 : account.smtpPort,
|
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
|
||||||
tls: true,
|
tls: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,22 @@ import nodemailer from 'nodemailer';
|
||||||
import { mailAccountFileService } from './mailAccountFileService';
|
import { mailAccountFileService } from './mailAccountFileService';
|
||||||
import { mailTemplateFileService } from './mailTemplateFileService';
|
import { mailTemplateFileService } from './mailTemplateFileService';
|
||||||
import { encryptionService } from './encryptionService';
|
import { encryptionService } from './encryptionService';
|
||||||
|
import { mailSentHistoryService } from './mailSentHistoryService';
|
||||||
|
|
||||||
export interface SendMailRequest {
|
export interface SendMailRequest {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
templateId?: string;
|
templateId?: string;
|
||||||
to: string[]; // 수신자 이메일 배열
|
to: string[]; // 받는 사람
|
||||||
|
cc?: string[]; // 참조 (Carbon Copy)
|
||||||
|
bcc?: string[]; // 숨은참조 (Blind Carbon Copy)
|
||||||
subject: string;
|
subject: string;
|
||||||
variables?: Record<string, string>; // 템플릿 변수 치환
|
variables?: Record<string, string>; // 템플릿 변수 치환
|
||||||
customHtml?: string; // 템플릿 없이 직접 HTML 작성 시
|
customHtml?: string; // 템플릿 없이 직접 HTML 작성 시
|
||||||
|
attachments?: Array<{ // 첨부파일
|
||||||
|
filename: string;
|
||||||
|
path: string;
|
||||||
|
contentType?: string;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendMailResult {
|
export interface SendMailResult {
|
||||||
|
|
@ -30,6 +38,8 @@ class MailSendSimpleService {
|
||||||
* 단일 메일 발송 또는 소규모 발송
|
* 단일 메일 발송 또는 소규모 발송
|
||||||
*/
|
*/
|
||||||
async sendMail(request: SendMailRequest): Promise<SendMailResult> {
|
async sendMail(request: SendMailRequest): Promise<SendMailResult> {
|
||||||
|
let htmlContent = ''; // 상위 스코프로 이동
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 계정 조회
|
// 1. 계정 조회
|
||||||
const account = await mailAccountFileService.getAccountById(request.accountId);
|
const account = await mailAccountFileService.getAccountById(request.accountId);
|
||||||
|
|
@ -43,7 +53,7 @@ class MailSendSimpleService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. HTML 생성 (템플릿 또는 커스텀)
|
// 3. HTML 생성 (템플릿 또는 커스텀)
|
||||||
let htmlContent = request.customHtml || '';
|
htmlContent = request.customHtml || '';
|
||||||
|
|
||||||
if (!htmlContent && request.templateId) {
|
if (!htmlContent && request.templateId) {
|
||||||
const template = await mailTemplateFileService.getTemplateById(request.templateId);
|
const template = await mailTemplateFileService.getTemplateById(request.templateId);
|
||||||
|
|
@ -59,20 +69,20 @@ class MailSendSimpleService {
|
||||||
|
|
||||||
// 4. 비밀번호 복호화
|
// 4. 비밀번호 복호화
|
||||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||||
console.log('🔐 비밀번호 복호화 완료');
|
// console.log('🔐 비밀번호 복호화 완료');
|
||||||
console.log('🔐 암호화된 비밀번호 (일부):', account.smtpPassword.substring(0, 30) + '...');
|
// console.log('🔐 암호화된 비밀번호 (일부):', account.smtpPassword.substring(0, 30) + '...');
|
||||||
console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
|
// console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
|
||||||
|
|
||||||
// 5. SMTP 연결 생성
|
// 5. SMTP 연결 생성
|
||||||
// 포트 465는 SSL/TLS를 사용해야 함
|
// 포트 465는 SSL/TLS를 사용해야 함
|
||||||
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
|
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
|
||||||
|
|
||||||
console.log('📧 SMTP 연결 설정:', {
|
// console.log('📧 SMTP 연결 설정:', {
|
||||||
host: account.smtpHost,
|
// host: account.smtpHost,
|
||||||
port: account.smtpPort,
|
// port: account.smtpPort,
|
||||||
secure: isSecure,
|
// secure: isSecure,
|
||||||
user: account.smtpUsername,
|
// user: account.smtpUsername,
|
||||||
});
|
// });
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
host: account.smtpHost,
|
host: account.smtpHost,
|
||||||
|
|
@ -89,13 +99,60 @@ class MailSendSimpleService {
|
||||||
|
|
||||||
console.log('📧 메일 발송 시도 중...');
|
console.log('📧 메일 발송 시도 중...');
|
||||||
|
|
||||||
// 6. 메일 발송
|
// 6. 메일 발송 (CC, BCC, 첨부파일 지원)
|
||||||
const info = await transporter.sendMail({
|
const mailOptions: any = {
|
||||||
from: `"${account.name}" <${account.email}>`,
|
from: `"${account.name}" <${account.email}>`,
|
||||||
to: request.to.join(', '),
|
to: request.to.join(', '),
|
||||||
subject: this.replaceVariables(request.subject, request.variables),
|
subject: this.replaceVariables(request.subject, request.variables),
|
||||||
html: htmlContent,
|
html: htmlContent,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// 참조(CC) 추가
|
||||||
|
if (request.cc && request.cc.length > 0) {
|
||||||
|
mailOptions.cc = request.cc.join(', ');
|
||||||
|
// console.log('📧 참조(CC):', request.cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숨은참조(BCC) 추가
|
||||||
|
if (request.bcc && request.bcc.length > 0) {
|
||||||
|
mailOptions.bcc = request.bcc.join(', ');
|
||||||
|
// console.log('🔒 숨은참조(BCC):', request.bcc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 첨부파일 추가 (한글 파일명 인코딩 처리)
|
||||||
|
if (request.attachments && request.attachments.length > 0) {
|
||||||
|
mailOptions.attachments = request.attachments.map(att => {
|
||||||
|
// 파일명에서 타임스탬프_랜덤숫자_ 부분 제거하여 원본 파일명 복원
|
||||||
|
let filename = att.filename.replace(/^\d+-\d+_/, '');
|
||||||
|
|
||||||
|
// NFC 정규화 (한글 조합 문자 정규화)
|
||||||
|
filename = filename.normalize('NFC');
|
||||||
|
|
||||||
|
// ISO-8859-1 호환을 위한 안전한 파일명 생성
|
||||||
|
// 한글이 포함된 경우 RFC 2047 MIME 인코딩 사용
|
||||||
|
const hasKorean = /[\uAC00-\uD7AF]/.test(filename);
|
||||||
|
let safeFilename = filename;
|
||||||
|
|
||||||
|
if (hasKorean) {
|
||||||
|
// 한글이 포함된 경우: RFC 2047 MIME 인코딩 사용
|
||||||
|
safeFilename = `=?UTF-8?B?${Buffer.from(filename, 'utf8').toString('base64')}?=`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
filename: safeFilename,
|
||||||
|
path: att.path,
|
||||||
|
contentType: att.contentType,
|
||||||
|
// 다중 호환성을 위한 헤더 설정
|
||||||
|
headers: {
|
||||||
|
'Content-Disposition': `attachment; filename="${safeFilename}"; filename*=UTF-8''${encodeURIComponent(filename)}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
console.log('📎 첨부파일 (원본):', request.attachments.map((a: any) => a.filename.replace(/^\d+-\d+_/, '')));
|
||||||
|
console.log('📎 첨부파일 (인코딩):', mailOptions.attachments.map((a: any) => a.filename));
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = await transporter.sendMail(mailOptions);
|
||||||
|
|
||||||
console.log('✅ 메일 발송 성공:', {
|
console.log('✅ 메일 발송 성공:', {
|
||||||
messageId: info.messageId,
|
messageId: info.messageId,
|
||||||
|
|
@ -103,6 +160,43 @@ class MailSendSimpleService {
|
||||||
rejected: info.rejected,
|
rejected: info.rejected,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 발송 이력 저장 (성공)
|
||||||
|
try {
|
||||||
|
const template = request.templateId
|
||||||
|
? await mailTemplateFileService.getTemplateById(request.templateId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// AttachmentInfo 형식으로 변환
|
||||||
|
const attachmentInfos = request.attachments?.map(att => ({
|
||||||
|
filename: att.filename,
|
||||||
|
originalName: att.filename,
|
||||||
|
size: 0, // multer에서 제공하지 않으므로 0으로 설정
|
||||||
|
path: att.path,
|
||||||
|
mimetype: att.contentType || 'application/octet-stream',
|
||||||
|
}));
|
||||||
|
|
||||||
|
await mailSentHistoryService.saveSentMail({
|
||||||
|
accountId: account.id,
|
||||||
|
accountName: account.name,
|
||||||
|
accountEmail: account.email,
|
||||||
|
to: request.to,
|
||||||
|
cc: request.cc,
|
||||||
|
bcc: request.bcc,
|
||||||
|
subject: this.replaceVariables(request.subject, request.variables),
|
||||||
|
htmlContent,
|
||||||
|
templateId: request.templateId,
|
||||||
|
templateName: template?.name,
|
||||||
|
attachments: attachmentInfos,
|
||||||
|
status: 'success',
|
||||||
|
messageId: info.messageId,
|
||||||
|
accepted: info.accepted as string[],
|
||||||
|
rejected: info.rejected as string[],
|
||||||
|
});
|
||||||
|
} catch (historyError) {
|
||||||
|
console.error('발송 이력 저장 실패:', historyError);
|
||||||
|
// 이력 저장 실패는 메일 발송 성공에 영향 주지 않음
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
messageId: info.messageId,
|
messageId: info.messageId,
|
||||||
|
|
@ -113,6 +207,52 @@ class MailSendSimpleService {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
console.error('❌ 메일 발송 실패:', err.message);
|
console.error('❌ 메일 발송 실패:', err.message);
|
||||||
console.error('❌ 에러 상세:', err);
|
console.error('❌ 에러 상세:', err);
|
||||||
|
|
||||||
|
// 발송 이력 저장 (실패)
|
||||||
|
try {
|
||||||
|
// 계정 정보 가져오기 (실패 시에도 필요)
|
||||||
|
let accountInfo = { name: 'Unknown', email: 'unknown@example.com' };
|
||||||
|
try {
|
||||||
|
const acc = await mailAccountFileService.getAccountById(request.accountId);
|
||||||
|
if (acc) {
|
||||||
|
accountInfo = { name: acc.name, email: acc.email };
|
||||||
|
}
|
||||||
|
} catch (accError) {
|
||||||
|
// 계정 조회 실패는 무시
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = request.templateId
|
||||||
|
? await mailTemplateFileService.getTemplateById(request.templateId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// AttachmentInfo 형식으로 변환
|
||||||
|
const attachmentInfos = request.attachments?.map(att => ({
|
||||||
|
filename: att.filename,
|
||||||
|
originalName: att.filename,
|
||||||
|
size: 0,
|
||||||
|
path: att.path,
|
||||||
|
mimetype: att.contentType || 'application/octet-stream',
|
||||||
|
}));
|
||||||
|
|
||||||
|
await mailSentHistoryService.saveSentMail({
|
||||||
|
accountId: request.accountId,
|
||||||
|
accountName: accountInfo.name,
|
||||||
|
accountEmail: accountInfo.email,
|
||||||
|
to: request.to,
|
||||||
|
cc: request.cc,
|
||||||
|
bcc: request.bcc,
|
||||||
|
subject: request.subject,
|
||||||
|
htmlContent: htmlContent || '',
|
||||||
|
templateId: request.templateId,
|
||||||
|
templateName: template?.name,
|
||||||
|
attachments: attachmentInfos,
|
||||||
|
status: 'failed',
|
||||||
|
errorMessage: err.message,
|
||||||
|
});
|
||||||
|
} catch (historyError) {
|
||||||
|
console.error('발송 이력 저장 실패:', historyError);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: err.message,
|
error: err.message,
|
||||||
|
|
@ -136,33 +276,24 @@ class MailSendSimpleService {
|
||||||
if (variables) {
|
if (variables) {
|
||||||
content = this.replaceVariables(content, variables);
|
content = this.replaceVariables(content, variables);
|
||||||
}
|
}
|
||||||
html += `<div style="${this.styleObjectToString(component.styles)}">${content}</div>`;
|
html += `<p style="margin: 16px 0; color: ${component.color || '#333'}; font-size: ${component.fontSize || '14px'};">${content}</p>`;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'button':
|
case 'button':
|
||||||
let buttonText = component.text || 'Button';
|
let buttonText = component.text || 'Button';
|
||||||
if (variables) {
|
if (variables) {
|
||||||
buttonText = this.replaceVariables(buttonText, variables);
|
buttonText = this.replaceVariables(buttonText, variables);
|
||||||
}
|
}
|
||||||
html += `
|
html += `<div style="text-align: center; margin: 24px 0;">
|
||||||
<a href="${component.url || '#'}" style="
|
<a href="${component.url || '#'}" style="display: inline-block; padding: 12px 24px; background-color: ${component.backgroundColor || '#007bff'}; color: ${component.textColor || '#fff'}; text-decoration: none; border-radius: 4px;">${buttonText}</a>
|
||||||
display: inline-block;
|
</div>`;
|
||||||
padding: 12px 24px;
|
|
||||||
background-color: ${component.styles?.backgroundColor || '#007bff'};
|
|
||||||
color: ${component.styles?.color || 'white'};
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
${this.styleObjectToString(component.styles)}
|
|
||||||
">${buttonText}</a>
|
|
||||||
`;
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'image':
|
case 'image':
|
||||||
html += `<img src="${component.src || ''}" style="max-width: 100%; ${this.styleObjectToString(component.styles)}" />`;
|
html += `<div style="text-align: center; margin: 16px 0;">
|
||||||
|
<img src="${component.src}" alt="${component.alt || ''}" style="max-width: 100%; height: auto;" />
|
||||||
|
</div>`;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'spacer':
|
case 'spacer':
|
||||||
html += `<div style="height: ${component.height || 20}px;"></div>`;
|
html += `<div style="height: ${component.height || '20px'};"></div>`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -174,10 +305,13 @@ class MailSendSimpleService {
|
||||||
/**
|
/**
|
||||||
* 변수 치환
|
* 변수 치환
|
||||||
*/
|
*/
|
||||||
private replaceVariables(text: string, variables?: Record<string, string>): string {
|
private replaceVariables(
|
||||||
if (!variables) return text;
|
content: string,
|
||||||
|
variables?: Record<string, string>
|
||||||
|
): string {
|
||||||
|
if (!variables) return content;
|
||||||
|
|
||||||
let result = text;
|
let result = content;
|
||||||
Object.entries(variables).forEach(([key, value]) => {
|
Object.entries(variables).forEach(([key, value]) => {
|
||||||
const regex = new RegExp(`\\{${key}\\}`, 'g');
|
const regex = new RegExp(`\\{${key}\\}`, 'g');
|
||||||
result = result.replace(regex, value);
|
result = result.replace(regex, value);
|
||||||
|
|
@ -186,48 +320,30 @@ class MailSendSimpleService {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 스타일 객체를 CSS 문자열로 변환
|
|
||||||
*/
|
|
||||||
private styleObjectToString(styles?: Record<string, string>): string {
|
|
||||||
if (!styles) return '';
|
|
||||||
return Object.entries(styles)
|
|
||||||
.map(([key, value]) => `${this.camelToKebab(key)}: ${value}`)
|
|
||||||
.join('; ');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* camelCase를 kebab-case로 변환
|
|
||||||
*/
|
|
||||||
private camelToKebab(str: string): string {
|
|
||||||
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SMTP 연결 테스트
|
* SMTP 연결 테스트
|
||||||
*/
|
*/
|
||||||
async testConnection(accountId: string): Promise<{ success: boolean; message: string }> {
|
async testConnection(accountId: string): Promise<{ success: boolean; message: string }> {
|
||||||
try {
|
try {
|
||||||
console.log('🔌 SMTP 연결 테스트 시작:', accountId);
|
|
||||||
|
|
||||||
const account = await mailAccountFileService.getAccountById(accountId);
|
const account = await mailAccountFileService.getAccountById(accountId);
|
||||||
if (!account) {
|
if (!account) {
|
||||||
throw new Error('계정을 찾을 수 없습니다.');
|
return { success: false, message: '메일 계정을 찾을 수 없습니다.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 비밀번호 복호화
|
// 비밀번호 복호화
|
||||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||||
console.log('🔐 비밀번호 복호화 완료');
|
// console.log('🔐 테스트용 비밀번호 복호화 완료');
|
||||||
|
// console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
|
||||||
|
|
||||||
// 포트 465는 SSL/TLS를 사용해야 함
|
// 포트 465는 SSL/TLS를 사용해야 함
|
||||||
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
|
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
|
||||||
|
|
||||||
console.log('🔌 SMTP 연결 설정:', {
|
// console.log('🧪 SMTP 연결 테스트 시작:', {
|
||||||
host: account.smtpHost,
|
// host: account.smtpHost,
|
||||||
port: account.smtpPort,
|
// port: account.smtpPort,
|
||||||
secure: isSecure,
|
// secure: isSecure,
|
||||||
user: account.smtpUsername,
|
// user: account.smtpUsername,
|
||||||
});
|
// });
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
host: account.smtpHost,
|
host: account.smtpHost,
|
||||||
|
|
@ -237,28 +353,22 @@ class MailSendSimpleService {
|
||||||
user: account.smtpUsername,
|
user: account.smtpUsername,
|
||||||
pass: decryptedPassword, // 복호화된 비밀번호 사용
|
pass: decryptedPassword, // 복호화된 비밀번호 사용
|
||||||
},
|
},
|
||||||
connectionTimeout: 10000, // 10초 타임아웃
|
// 테스트용 타임아웃 (10초)
|
||||||
|
connectionTimeout: 10000,
|
||||||
greetingTimeout: 10000,
|
greetingTimeout: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('🔌 SMTP 연결 검증 중...');
|
// 연결 테스트
|
||||||
await transporter.verify();
|
await transporter.verify();
|
||||||
console.log('✅ SMTP 연결 검증 성공!');
|
|
||||||
|
|
||||||
return {
|
console.log('✅ SMTP 연결 테스트 성공');
|
||||||
success: true,
|
return { success: true, message: 'SMTP 연결이 성공했습니다.' };
|
||||||
message: 'SMTP 연결 성공!',
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
console.error('❌ SMTP 연결 실패:', err.message);
|
console.error('❌ SMTP 연결 테스트 실패:', err.message);
|
||||||
return {
|
return { success: false, message: `SMTP 연결 실패: ${err.message}` };
|
||||||
success: false,
|
|
||||||
message: `연결 실패: ${err.message}`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mailSendSimpleService = new MailSendSimpleService();
|
export const mailSendSimpleService = new MailSendSimpleService();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,232 @@
|
||||||
|
/**
|
||||||
|
* 메일 발송 이력 관리 서비스 (파일 기반)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import {
|
||||||
|
SentMailHistory,
|
||||||
|
SentMailListQuery,
|
||||||
|
SentMailListResponse,
|
||||||
|
AttachmentInfo,
|
||||||
|
} from '../types/mailSentHistory';
|
||||||
|
|
||||||
|
const SENT_MAIL_DIR = path.join(__dirname, '../../data/mail-sent');
|
||||||
|
|
||||||
|
class MailSentHistoryService {
|
||||||
|
constructor() {
|
||||||
|
// 디렉토리 생성 (없으면)
|
||||||
|
if (!fs.existsSync(SENT_MAIL_DIR)) {
|
||||||
|
fs.mkdirSync(SENT_MAIL_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 발송 이력 저장
|
||||||
|
*/
|
||||||
|
async saveSentMail(data: Omit<SentMailHistory, 'id' | 'sentAt'>): Promise<SentMailHistory> {
|
||||||
|
const history: SentMailHistory = {
|
||||||
|
id: uuidv4(),
|
||||||
|
sentAt: new Date().toISOString(),
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
|
||||||
|
const filePath = path.join(SENT_MAIL_DIR, `${history.id}.json`);
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(history, null, 2), 'utf-8');
|
||||||
|
|
||||||
|
console.log('💾 발송 이력 저장:', history.id);
|
||||||
|
return history;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 발송 이력 목록 조회 (필터링, 페이징)
|
||||||
|
*/
|
||||||
|
async getSentMailList(query: SentMailListQuery): Promise<SentMailListResponse> {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
limit = 20,
|
||||||
|
searchTerm = '',
|
||||||
|
status = 'all',
|
||||||
|
accountId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
sortBy = 'sentAt',
|
||||||
|
sortOrder = 'desc',
|
||||||
|
} = query;
|
||||||
|
|
||||||
|
// 모든 발송 이력 파일 읽기
|
||||||
|
const files = fs.readdirSync(SENT_MAIL_DIR).filter((f) => f.endsWith('.json'));
|
||||||
|
let allHistory: SentMailHistory[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const filePath = path.join(SENT_MAIL_DIR, file);
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const history: SentMailHistory = JSON.parse(content);
|
||||||
|
allHistory.push(history);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`발송 이력 파일 읽기 실패: ${file}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필터링
|
||||||
|
let filtered = allHistory;
|
||||||
|
|
||||||
|
// 상태 필터
|
||||||
|
if (status !== 'all') {
|
||||||
|
filtered = filtered.filter((h) => h.status === status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 계정 필터
|
||||||
|
if (accountId) {
|
||||||
|
filtered = filtered.filter((h) => h.accountId === accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜 필터
|
||||||
|
if (startDate) {
|
||||||
|
filtered = filtered.filter((h) => h.sentAt >= startDate);
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
filtered = filtered.filter((h) => h.sentAt <= endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검색어 필터 (제목, 받는사람)
|
||||||
|
if (searchTerm) {
|
||||||
|
const term = searchTerm.toLowerCase();
|
||||||
|
filtered = filtered.filter(
|
||||||
|
(h) =>
|
||||||
|
h.subject.toLowerCase().includes(term) ||
|
||||||
|
h.to.some((email) => email.toLowerCase().includes(term)) ||
|
||||||
|
(h.cc && h.cc.some((email) => email.toLowerCase().includes(term)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
let aVal: any = a[sortBy];
|
||||||
|
let bVal: any = b[sortBy];
|
||||||
|
|
||||||
|
if (sortBy === 'sentAt') {
|
||||||
|
aVal = new Date(aVal).getTime();
|
||||||
|
bVal = new Date(bVal).getTime();
|
||||||
|
} else {
|
||||||
|
aVal = aVal ? aVal.toLowerCase() : '';
|
||||||
|
bVal = bVal ? bVal.toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortOrder === 'asc') {
|
||||||
|
return aVal > bVal ? 1 : -1;
|
||||||
|
} else {
|
||||||
|
return aVal < bVal ? 1 : -1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 페이징
|
||||||
|
const total = filtered.length;
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
const start = (page - 1) * limit;
|
||||||
|
const end = start + limit;
|
||||||
|
const items = filtered.slice(start, end);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 발송 이력 조회
|
||||||
|
*/
|
||||||
|
async getSentMailById(id: string): Promise<SentMailHistory | null> {
|
||||||
|
const filePath = path.join(SENT_MAIL_DIR, `${id}.json`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
return JSON.parse(content) as SentMailHistory;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('발송 이력 읽기 실패:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 발송 이력 삭제
|
||||||
|
*/
|
||||||
|
async deleteSentMail(id: string): Promise<boolean> {
|
||||||
|
const filePath = path.join(SENT_MAIL_DIR, `${id}.json`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
console.log('🗑️ 발송 이력 삭제:', id);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('발송 이력 삭제 실패:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 통계 조회
|
||||||
|
*/
|
||||||
|
async getStatistics(accountId?: string): Promise<{
|
||||||
|
totalSent: number;
|
||||||
|
successCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
todayCount: number;
|
||||||
|
thisMonthCount: number;
|
||||||
|
successRate: number;
|
||||||
|
}> {
|
||||||
|
const files = fs.readdirSync(SENT_MAIL_DIR).filter((f) => f.endsWith('.json'));
|
||||||
|
let allHistory: SentMailHistory[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const filePath = path.join(SENT_MAIL_DIR, file);
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const history: SentMailHistory = JSON.parse(content);
|
||||||
|
|
||||||
|
// 계정 필터
|
||||||
|
if (!accountId || history.accountId === accountId) {
|
||||||
|
allHistory.push(history);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`발송 이력 파일 읽기 실패: ${file}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString();
|
||||||
|
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
|
||||||
|
|
||||||
|
const totalSent = allHistory.length;
|
||||||
|
const successCount = allHistory.filter((h) => h.status === 'success').length;
|
||||||
|
const failedCount = allHistory.filter((h) => h.status === 'failed').length;
|
||||||
|
const todayCount = allHistory.filter((h) => h.sentAt >= todayStart).length;
|
||||||
|
const thisMonthCount = allHistory.filter((h) => h.sentAt >= monthStart).length;
|
||||||
|
const successRate = totalSent > 0 ? Math.round((successCount / totalSent) * 100) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSent,
|
||||||
|
successCount,
|
||||||
|
failedCount,
|
||||||
|
todayCount,
|
||||||
|
thisMonthCount,
|
||||||
|
successRate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mailSentHistoryService = new MailSentHistoryService();
|
||||||
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
/**
|
||||||
|
* 발송 이력 타입 정의
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SentMailHistory {
|
||||||
|
id: string; // UUID
|
||||||
|
accountId: string; // 발송 계정 ID
|
||||||
|
accountName: string; // 발송 계정 이름
|
||||||
|
accountEmail: string; // 발송 계정 이메일
|
||||||
|
|
||||||
|
// 수신자 정보
|
||||||
|
to: string[]; // 받는 사람
|
||||||
|
cc?: string[]; // 참조
|
||||||
|
bcc?: string[]; // 숨은참조
|
||||||
|
|
||||||
|
// 메일 내용
|
||||||
|
subject: string; // 제목
|
||||||
|
htmlContent: string; // HTML 내용
|
||||||
|
templateId?: string; // 사용한 템플릿 ID (있는 경우)
|
||||||
|
templateName?: string; // 사용한 템플릿 이름
|
||||||
|
|
||||||
|
// 첨부파일 정보
|
||||||
|
attachments?: AttachmentInfo[];
|
||||||
|
|
||||||
|
// 발송 정보
|
||||||
|
sentAt: string; // 발송 시간 (ISO 8601)
|
||||||
|
status: 'success' | 'failed'; // 발송 상태
|
||||||
|
messageId?: string; // SMTP 메시지 ID (성공 시)
|
||||||
|
errorMessage?: string; // 오류 메시지 (실패 시)
|
||||||
|
|
||||||
|
// 발송 결과
|
||||||
|
accepted?: string[]; // 수락된 이메일 주소
|
||||||
|
rejected?: string[]; // 거부된 이메일 주소
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttachmentInfo {
|
||||||
|
filename: string; // 저장된 파일명
|
||||||
|
originalName: string; // 원본 파일명
|
||||||
|
size: number; // 파일 크기 (bytes)
|
||||||
|
path: string; // 파일 경로
|
||||||
|
mimetype: string; // MIME 타입
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SentMailListQuery {
|
||||||
|
page?: number; // 페이지 번호 (1부터 시작)
|
||||||
|
limit?: number; // 페이지당 항목 수
|
||||||
|
searchTerm?: string; // 검색어 (제목, 받는사람)
|
||||||
|
status?: 'success' | 'failed' | 'all'; // 필터: 상태
|
||||||
|
accountId?: string; // 필터: 발송 계정
|
||||||
|
startDate?: string; // 필터: 시작 날짜 (ISO 8601)
|
||||||
|
endDate?: string; // 필터: 종료 날짜 (ISO 8601)
|
||||||
|
sortBy?: 'sentAt' | 'subject'; // 정렬 기준
|
||||||
|
sortOrder?: 'asc' | 'desc'; // 정렬 순서
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SentMailListResponse {
|
||||||
|
items: SentMailHistory[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Mail, Plus, Loader2, RefreshCw } from "lucide-react";
|
import { Mail, Plus, Loader2, RefreshCw, LayoutDashboard } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
MailAccount,
|
MailAccount,
|
||||||
getMailAccounts,
|
getMailAccounts,
|
||||||
|
|
@ -19,6 +20,7 @@ import MailAccountTable from "@/components/mail/MailAccountTable";
|
||||||
import ConfirmDeleteModal from "@/components/mail/ConfirmDeleteModal";
|
import ConfirmDeleteModal from "@/components/mail/ConfirmDeleteModal";
|
||||||
|
|
||||||
export default function MailAccountsPage() {
|
export default function MailAccountsPage() {
|
||||||
|
const router = useRouter();
|
||||||
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
|
@ -133,6 +135,14 @@ export default function MailAccountsPage() {
|
||||||
<p className="mt-2 text-gray-600">SMTP 메일 계정을 관리하고 발송 통계를 확인합니다</p>
|
<p className="mt-2 text-gray-600">SMTP 메일 계정을 관리하고 발송 통계를 확인합니다</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push('/admin/mail/dashboard')}
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="w-4 h-4 mr-2" />
|
||||||
|
대시보드
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import {
|
||||||
Calendar,
|
Calendar,
|
||||||
Clock
|
Clock
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getMailAccounts, getMailTemplates } from "@/lib/api/mail";
|
import { getMailAccounts, getMailTemplates, getMailStatistics } from "@/lib/api/mail";
|
||||||
|
|
||||||
interface DashboardStats {
|
interface DashboardStats {
|
||||||
totalAccounts: number;
|
totalAccounts: number;
|
||||||
|
|
@ -39,19 +39,22 @@ export default function MailDashboardPage() {
|
||||||
const loadStats = async () => {
|
const loadStats = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// 계정 수 (apiClient를 통해 토큰 포함)
|
// 계정 수
|
||||||
const accounts = await getMailAccounts();
|
const accounts = await getMailAccounts();
|
||||||
|
|
||||||
// 템플릿 수 (apiClient를 통해 토큰 포함)
|
// 템플릿 수
|
||||||
const templates = await getMailTemplates();
|
const templates = await getMailTemplates();
|
||||||
|
|
||||||
|
// 발송 통계
|
||||||
|
const mailStats = await getMailStatistics();
|
||||||
|
|
||||||
setStats({
|
setStats({
|
||||||
totalAccounts: accounts.length,
|
totalAccounts: accounts.length,
|
||||||
totalTemplates: templates.length,
|
totalTemplates: templates.length,
|
||||||
sentToday: 0, // TODO: 실제 발송 통계 API 연동
|
sentToday: mailStats.todayCount,
|
||||||
receivedToday: 0,
|
receivedToday: 0, // 수신함 기능은 별도
|
||||||
sentThisMonth: 0,
|
sentThisMonth: mailStats.thisMonthCount,
|
||||||
successRate: 0,
|
successRate: mailStats.successRate,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('통계 로드 실패:', error);
|
// console.error('통계 로드 실패:', error);
|
||||||
|
|
@ -229,6 +232,17 @@ export default function MailDashboardPage() {
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/admin/mail/sent"
|
||||||
|
className="flex items-center p-4 rounded-lg border border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all"
|
||||||
|
>
|
||||||
|
<Inbox className="w-8 h-8 text-indigo-500 mr-3" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">보낸메일함</p>
|
||||||
|
<p className="text-sm text-gray-500">발송 이력 확인</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="/admin/mail/receive"
|
href="/admin/mail/receive"
|
||||||
className="flex items-center p-4 rounded-lg border border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all"
|
className="flex items-center p-4 rounded-lg border border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all"
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,9 @@ import {
|
||||||
Filter,
|
Filter,
|
||||||
SortAsc,
|
SortAsc,
|
||||||
SortDesc,
|
SortDesc,
|
||||||
|
LayoutDashboard,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
MailAccount,
|
MailAccount,
|
||||||
ReceivedMail,
|
ReceivedMail,
|
||||||
|
|
@ -26,6 +28,7 @@ import {
|
||||||
import MailDetailModal from "@/components/mail/MailDetailModal";
|
import MailDetailModal from "@/components/mail/MailDetailModal";
|
||||||
|
|
||||||
export default function MailReceivePage() {
|
export default function MailReceivePage() {
|
||||||
|
const router = useRouter();
|
||||||
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
||||||
const [selectedAccountId, setSelectedAccountId] = useState<string>("");
|
const [selectedAccountId, setSelectedAccountId] = useState<string>("");
|
||||||
const [mails, setMails] = useState<ReceivedMail[]>([]);
|
const [mails, setMails] = useState<ReceivedMail[]>([]);
|
||||||
|
|
@ -208,6 +211,14 @@ export default function MailReceivePage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push('/admin/mail/dashboard')}
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="w-4 h-4 mr-2" />
|
||||||
|
대시보드
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,616 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect } 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 {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Inbox,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
Eye,
|
||||||
|
Trash2,
|
||||||
|
RefreshCw,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Mail,
|
||||||
|
Calendar,
|
||||||
|
User,
|
||||||
|
Paperclip,
|
||||||
|
Loader2,
|
||||||
|
X,
|
||||||
|
File,
|
||||||
|
LayoutDashboard,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
SentMailHistory,
|
||||||
|
getSentMailList,
|
||||||
|
deleteSentMail,
|
||||||
|
getMailAccounts,
|
||||||
|
MailAccount,
|
||||||
|
} from "@/lib/api/mail";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
export default function SentMailPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [mails, setMails] = useState<SentMailHistory[]>([]);
|
||||||
|
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [selectedMail, setSelectedMail] = useState<SentMailHistory | null>(null);
|
||||||
|
|
||||||
|
// 필터 및 페이징
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [filterStatus, setFilterStatus] = useState<'all' | 'success' | 'failed'>('all');
|
||||||
|
const [filterAccountId, setFilterAccountId] = useState<string>('all');
|
||||||
|
const [sortBy, setSortBy] = useState<'sentAt' | 'subject'>('sentAt');
|
||||||
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAccounts();
|
||||||
|
loadMails();
|
||||||
|
}, [page, filterStatus, filterAccountId, sortBy, sortOrder]);
|
||||||
|
|
||||||
|
const loadAccounts = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getMailAccounts();
|
||||||
|
setAccounts(data);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
toast({
|
||||||
|
title: "계정 로드 실패",
|
||||||
|
description: err.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMails = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const result = await getSentMailList({
|
||||||
|
page,
|
||||||
|
limit: 20,
|
||||||
|
searchTerm: searchTerm || undefined,
|
||||||
|
status: filterStatus,
|
||||||
|
accountId: filterAccountId !== 'all' ? filterAccountId : undefined,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
});
|
||||||
|
|
||||||
|
setMails(result.items);
|
||||||
|
setTotalPages(result.totalPages);
|
||||||
|
setTotal(result.total);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
toast({
|
||||||
|
title: "발송 이력 로드 실패",
|
||||||
|
description: err.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setPage(1);
|
||||||
|
loadMails();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
if (!confirm("이 발송 이력을 삭제하시겠습니까?")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteSentMail(id);
|
||||||
|
toast({
|
||||||
|
title: "삭제 완료",
|
||||||
|
description: "발송 이력이 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
loadMails();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const err = error as Error;
|
||||||
|
toast({
|
||||||
|
title: "삭제 실패",
|
||||||
|
description: err.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString('ko-KR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && page === 1) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6 bg-slate-50 min-h-screen">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
<Inbox className="w-8 h-8" />
|
||||||
|
보낸메일함
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-gray-600">발송된 메일의 이력을 확인하고 관리하세요</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push('/admin/mail/dashboard')}
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="w-4 h-4 mr-2" />
|
||||||
|
대시보드
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 및 검색 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Filter className="w-5 h-5" />
|
||||||
|
필터 및 검색
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Label htmlFor="search">검색</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="search"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
placeholder="제목 또는 받는사람 검색..."
|
||||||
|
/>
|
||||||
|
<Button onClick={handleSearch} size="icon">
|
||||||
|
<Search className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상태 필터 */}
|
||||||
|
<div>
|
||||||
|
<Label>상태</Label>
|
||||||
|
<Select value={filterStatus} onValueChange={(v: any) => {
|
||||||
|
setFilterStatus(v);
|
||||||
|
setPage(1);
|
||||||
|
}}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체</SelectItem>
|
||||||
|
<SelectItem value="success">성공</SelectItem>
|
||||||
|
<SelectItem value="failed">실패</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 계정 필터 */}
|
||||||
|
<div>
|
||||||
|
<Label>발송 계정</Label>
|
||||||
|
<Select value={filterAccountId} onValueChange={(v) => {
|
||||||
|
setFilterAccountId(v);
|
||||||
|
setPage(1);
|
||||||
|
}}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">전체 계정</SelectItem>
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<SelectItem key={account.id} value={account.id}>
|
||||||
|
{account.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSortBy('sentAt');
|
||||||
|
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Calendar className="w-4 h-4 mr-2" />
|
||||||
|
날짜순 {sortBy === 'sentAt' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSortBy('subject');
|
||||||
|
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
제목순 {sortBy === 'subject' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={loadMails}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 메일 목록 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
발송 이력 ({total}건)
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{mails.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Mail className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||||
|
<p className="text-gray-500">발송된 메일이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{mails.map((mail) => (
|
||||||
|
<div
|
||||||
|
key={mail.id}
|
||||||
|
className="flex items-center justify-between p-4 border rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
{mail.status === 'success' ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-green-500 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<h3 className="font-medium text-gray-900 truncate">
|
||||||
|
{mail.subject}
|
||||||
|
</h3>
|
||||||
|
{mail.attachments && mail.attachments.length > 0 && (
|
||||||
|
<Paperclip className="w-4 h-4 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<User className="w-3 h-3" />
|
||||||
|
<span>{mail.accountName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Mail className="w-3 h-3" />
|
||||||
|
<span>받는사람: {mail.to.length}명</span>
|
||||||
|
{mail.cc && mail.cc.length > 0 && (
|
||||||
|
<span className="text-gray-400">(참조 {mail.cc.length}명)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
<span>{formatDate(mail.sentAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{mail.status === 'failed' && mail.errorMessage && (
|
||||||
|
<div className="mt-1 text-sm text-red-600">
|
||||||
|
오류: {mail.errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 ml-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedMail(mail)}
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(mail.id)}
|
||||||
|
className="text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 페이징 */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex justify-center gap-2 mt-6">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={page === 1}
|
||||||
|
>
|
||||||
|
이전
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center px-4 text-sm text-gray-600">
|
||||||
|
{page} / {totalPages}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 상세보기 모달 */}
|
||||||
|
<Dialog open={selectedMail !== null} onOpenChange={(open) => !open && setSelectedMail(null)}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{selectedMail?.status === 'success' ? (
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="w-5 h-5 text-red-500" />
|
||||||
|
)}
|
||||||
|
<span>발송 상세 정보</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedMail(null)}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{selectedMail && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 발송 정보 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">발송 정보</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium text-gray-700">발송 계정</Label>
|
||||||
|
<p className="text-sm text-gray-900 mt-1">
|
||||||
|
{selectedMail.accountName} ({selectedMail.accountEmail})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium text-gray-700">발송 시간</Label>
|
||||||
|
<p className="text-sm text-gray-900 mt-1">
|
||||||
|
{formatDate(selectedMail.sentAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium text-gray-700">상태</Label>
|
||||||
|
<div className="mt-1">
|
||||||
|
{selectedMail.status === 'success' ? (
|
||||||
|
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700">
|
||||||
|
발송 성공
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700">
|
||||||
|
발송 실패
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedMail.messageId && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium text-gray-700">메시지 ID</Label>
|
||||||
|
<p className="text-sm text-gray-600 mt-1 font-mono">
|
||||||
|
{selectedMail.messageId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedMail.errorMessage && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium text-red-700">오류 메시지</Label>
|
||||||
|
<p className="text-sm text-red-600 mt-1">
|
||||||
|
{selectedMail.errorMessage}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 수신자 정보 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">수신자 정보</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium text-gray-700">받는 사람</Label>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-1">
|
||||||
|
{selectedMail.to.map((email, i) => (
|
||||||
|
<span key={i} className="px-2 py-1 text-xs rounded bg-blue-100 text-blue-700">
|
||||||
|
{email}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedMail.cc && selectedMail.cc.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium text-gray-700">참조 (CC)</Label>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-1">
|
||||||
|
{selectedMail.cc.map((email, i) => (
|
||||||
|
<span key={i} className="px-2 py-1 text-xs rounded bg-green-100 text-green-700">
|
||||||
|
{email}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedMail.bcc && selectedMail.bcc.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium text-gray-700">숨은참조 (BCC)</Label>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-1">
|
||||||
|
{selectedMail.bcc.map((email, i) => (
|
||||||
|
<span key={i} className="px-2 py-1 text-xs rounded bg-purple-100 text-purple-700">
|
||||||
|
{email}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedMail.accepted && selectedMail.accepted.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium text-green-700">수락됨</Label>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
{selectedMail.accepted.join(", ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedMail.rejected && selectedMail.rejected.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium text-red-700">거부됨</Label>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
{selectedMail.rejected.join(", ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 메일 내용 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg">메일 내용</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium text-gray-700">제목</Label>
|
||||||
|
<p className="text-sm text-gray-900 mt-1 font-medium">
|
||||||
|
{selectedMail.subject}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedMail.templateName && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium text-gray-700">사용한 템플릿</Label>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
{selectedMail.templateName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-medium text-gray-700">내용 미리보기</Label>
|
||||||
|
<div
|
||||||
|
className="mt-2 border rounded-lg p-4 bg-white max-h-96 overflow-y-auto"
|
||||||
|
dangerouslySetInnerHTML={{ __html: selectedMail.htmlContent }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 첨부파일 */}
|
||||||
|
{selectedMail.attachments && selectedMail.attachments.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Paperclip className="w-5 h-5" />
|
||||||
|
첨부파일 ({selectedMail.attachments.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{selectedMail.attachments.map((file, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<File className="w-5 h-5 text-gray-500 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 truncate">
|
||||||
|
{file.originalName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{formatFileSize(file.size)} • {file.mimetype}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Plus, FileText, Loader2, RefreshCw, Search } from "lucide-react";
|
import { Plus, FileText, Loader2, RefreshCw, Search, LayoutDashboard } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
MailTemplate,
|
MailTemplate,
|
||||||
getMailTemplates,
|
getMailTemplates,
|
||||||
|
|
@ -19,6 +20,7 @@ import MailTemplateEditorModal from "@/components/mail/MailTemplateEditorModal";
|
||||||
import ConfirmDeleteModal from "@/components/mail/ConfirmDeleteModal";
|
import ConfirmDeleteModal from "@/components/mail/ConfirmDeleteModal";
|
||||||
|
|
||||||
export default function MailTemplatesPage() {
|
export default function MailTemplatesPage() {
|
||||||
|
const router = useRouter();
|
||||||
const [templates, setTemplates] = useState<MailTemplate[]>([]);
|
const [templates, setTemplates] = useState<MailTemplate[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
@ -137,6 +139,14 @@ export default function MailTemplatesPage() {
|
||||||
<p className="mt-2 text-gray-600">드래그 앤 드롭으로 메일 템플릿을 만들고 관리합니다</p>
|
<p className="mt-2 text-gray-600">드래그 앤 드롭으로 메일 템플릿을 만들고 관리합니다</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => router.push('/admin/mail/dashboard')}
|
||||||
|
>
|
||||||
|
<LayoutDashboard className="w-4 h-4 mr-2" />
|
||||||
|
대시보드
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
|
|
@ -70,12 +70,76 @@ export interface UpdateMailTemplateDto extends Partial<CreateMailTemplateDto> {}
|
||||||
export interface SendMailDto {
|
export interface SendMailDto {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
templateId?: string;
|
templateId?: string;
|
||||||
to: string[]; // 수신자 이메일 배열
|
to: string[]; // 받는 사람
|
||||||
|
cc?: string[]; // 참조 (Carbon Copy)
|
||||||
|
bcc?: string[]; // 숨은참조 (Blind Carbon Copy)
|
||||||
subject: string;
|
subject: string;
|
||||||
variables?: Record<string, string>; // 템플릿 변수 치환
|
variables?: Record<string, string>; // 템플릿 변수 치환
|
||||||
customHtml?: string; // 템플릿 없이 직접 HTML 작성 시
|
customHtml?: string; // 템플릿 없이 직접 HTML 작성 시
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 발송 이력 타입
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface AttachmentInfo {
|
||||||
|
filename: string;
|
||||||
|
originalName: string;
|
||||||
|
size: number;
|
||||||
|
path: string;
|
||||||
|
mimetype: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SentMailHistory {
|
||||||
|
id: string;
|
||||||
|
accountId: string;
|
||||||
|
accountName: string;
|
||||||
|
accountEmail: string;
|
||||||
|
to: string[];
|
||||||
|
cc?: string[];
|
||||||
|
bcc?: string[];
|
||||||
|
subject: string;
|
||||||
|
htmlContent: string;
|
||||||
|
templateId?: string;
|
||||||
|
templateName?: string;
|
||||||
|
attachments?: AttachmentInfo[];
|
||||||
|
sentAt: string;
|
||||||
|
status: 'success' | 'failed';
|
||||||
|
messageId?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
accepted?: string[];
|
||||||
|
rejected?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SentMailListQuery {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
searchTerm?: string;
|
||||||
|
status?: 'success' | 'failed' | 'all';
|
||||||
|
accountId?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
sortBy?: 'sentAt' | 'subject';
|
||||||
|
sortOrder?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SentMailListResponse {
|
||||||
|
items: SentMailHistory[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MailStatistics {
|
||||||
|
totalSent: number;
|
||||||
|
successCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
todayCount: number;
|
||||||
|
thisMonthCount: number;
|
||||||
|
successRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MailSendResult {
|
export interface MailSendResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
|
|
@ -96,7 +160,7 @@ async function fetchApi<T>(
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient({
|
const response = await apiClient({
|
||||||
url: `/mail${endpoint}`,
|
url: endpoint, // `/mail` 접두사 제거 (apiClient는 이미 /api를 포함)
|
||||||
method,
|
method,
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
|
|
@ -124,14 +188,14 @@ async function fetchApi<T>(
|
||||||
* 전체 메일 계정 목록 조회
|
* 전체 메일 계정 목록 조회
|
||||||
*/
|
*/
|
||||||
export async function getMailAccounts(): Promise<MailAccount[]> {
|
export async function getMailAccounts(): Promise<MailAccount[]> {
|
||||||
return fetchApi<MailAccount[]>('/accounts');
|
return fetchApi<MailAccount[]>('/mail/accounts');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 메일 계정 조회
|
* 특정 메일 계정 조회
|
||||||
*/
|
*/
|
||||||
export async function getMailAccount(id: string): Promise<MailAccount> {
|
export async function getMailAccount(id: string): Promise<MailAccount> {
|
||||||
return fetchApi<MailAccount>(`/accounts/${id}`);
|
return fetchApi<MailAccount>(`/mail/accounts/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -140,7 +204,7 @@ export async function getMailAccount(id: string): Promise<MailAccount> {
|
||||||
export async function createMailAccount(
|
export async function createMailAccount(
|
||||||
data: CreateMailAccountDto
|
data: CreateMailAccountDto
|
||||||
): Promise<MailAccount> {
|
): Promise<MailAccount> {
|
||||||
return fetchApi<MailAccount>('/accounts', {
|
return fetchApi<MailAccount>('/mail/accounts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
|
|
@ -153,7 +217,7 @@ export async function updateMailAccount(
|
||||||
id: string,
|
id: string,
|
||||||
data: UpdateMailAccountDto
|
data: UpdateMailAccountDto
|
||||||
): Promise<MailAccount> {
|
): Promise<MailAccount> {
|
||||||
return fetchApi<MailAccount>(`/accounts/${id}`, {
|
return fetchApi<MailAccount>(`/mail/accounts/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
|
|
@ -163,7 +227,7 @@ export async function updateMailAccount(
|
||||||
* 메일 계정 삭제
|
* 메일 계정 삭제
|
||||||
*/
|
*/
|
||||||
export async function deleteMailAccount(id: string): Promise<{ success: boolean }> {
|
export async function deleteMailAccount(id: string): Promise<{ success: boolean }> {
|
||||||
return fetchApi<{ success: boolean }>(`/accounts/${id}`, {
|
return fetchApi<{ success: boolean }>(`/mail/accounts/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -172,7 +236,7 @@ export async function deleteMailAccount(id: string): Promise<{ success: boolean
|
||||||
* SMTP 연결 테스트
|
* SMTP 연결 테스트
|
||||||
*/
|
*/
|
||||||
export async function testMailAccountConnection(id: string): Promise<{ success: boolean; message: string }> {
|
export async function testMailAccountConnection(id: string): Promise<{ success: boolean; message: string }> {
|
||||||
return fetchApi<{ success: boolean; message: string }>(`/accounts/${id}/test-connection`, {
|
return fetchApi<{ success: boolean; message: string }>(`/mail/accounts/${id}/test-connection`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -185,7 +249,7 @@ export async function testMailConnection(id: string): Promise<{
|
||||||
message: string;
|
message: string;
|
||||||
}> {
|
}> {
|
||||||
return fetchApi<{ success: boolean; message: string }>(
|
return fetchApi<{ success: boolean; message: string }>(
|
||||||
`/accounts/${id}/test-connection`,
|
`/mail/accounts/${id}/test-connection`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
}
|
}
|
||||||
|
|
@ -200,14 +264,14 @@ export async function testMailConnection(id: string): Promise<{
|
||||||
* 전체 메일 템플릿 목록 조회
|
* 전체 메일 템플릿 목록 조회
|
||||||
*/
|
*/
|
||||||
export async function getMailTemplates(): Promise<MailTemplate[]> {
|
export async function getMailTemplates(): Promise<MailTemplate[]> {
|
||||||
return fetchApi<MailTemplate[]>('/templates-file');
|
return fetchApi<MailTemplate[]>('/mail/templates-file');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 메일 템플릿 조회
|
* 특정 메일 템플릿 조회
|
||||||
*/
|
*/
|
||||||
export async function getMailTemplate(id: string): Promise<MailTemplate> {
|
export async function getMailTemplate(id: string): Promise<MailTemplate> {
|
||||||
return fetchApi<MailTemplate>(`/templates-file/${id}`);
|
return fetchApi<MailTemplate>(`/mail/templates-file/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -216,7 +280,7 @@ export async function getMailTemplate(id: string): Promise<MailTemplate> {
|
||||||
export async function createMailTemplate(
|
export async function createMailTemplate(
|
||||||
data: CreateMailTemplateDto
|
data: CreateMailTemplateDto
|
||||||
): Promise<MailTemplate> {
|
): Promise<MailTemplate> {
|
||||||
return fetchApi<MailTemplate>('/templates-file', {
|
return fetchApi<MailTemplate>('/mail/templates-file', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
|
|
@ -229,7 +293,7 @@ export async function updateMailTemplate(
|
||||||
id: string,
|
id: string,
|
||||||
data: UpdateMailTemplateDto
|
data: UpdateMailTemplateDto
|
||||||
): Promise<MailTemplate> {
|
): Promise<MailTemplate> {
|
||||||
return fetchApi<MailTemplate>(`/templates-file/${id}`, {
|
return fetchApi<MailTemplate>(`/mail/templates-file/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
|
|
@ -239,7 +303,7 @@ export async function updateMailTemplate(
|
||||||
* 메일 템플릿 삭제
|
* 메일 템플릿 삭제
|
||||||
*/
|
*/
|
||||||
export async function deleteMailTemplate(id: string): Promise<{ success: boolean }> {
|
export async function deleteMailTemplate(id: string): Promise<{ success: boolean }> {
|
||||||
return fetchApi<{ success: boolean }>(`/templates-file/${id}`, {
|
return fetchApi<{ success: boolean }>(`/mail/templates-file/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -251,7 +315,7 @@ export async function previewMailTemplate(
|
||||||
id: string,
|
id: string,
|
||||||
sampleData?: Record<string, string>
|
sampleData?: Record<string, string>
|
||||||
): Promise<{ html: string }> {
|
): Promise<{ html: string }> {
|
||||||
return fetchApi<{ html: string }>(`/templates-file/${id}/preview`, {
|
return fetchApi<{ html: string }>(`/mail/templates-file/${id}/preview`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { sampleData },
|
data: { sampleData },
|
||||||
});
|
});
|
||||||
|
|
@ -265,7 +329,7 @@ export async function previewMailTemplate(
|
||||||
* 메일 발송 (단건 또는 소규모 발송)
|
* 메일 발송 (단건 또는 소규모 발송)
|
||||||
*/
|
*/
|
||||||
export async function sendMail(data: SendMailDto): Promise<MailSendResult> {
|
export async function sendMail(data: SendMailDto): Promise<MailSendResult> {
|
||||||
return fetchApi<MailSendResult>('/send/simple', {
|
return fetchApi<MailSendResult>('/mail/send/simple', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
|
|
@ -439,3 +503,52 @@ export async function testImapConnection(
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 발송 이력 API
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 발송 이력 목록 조회
|
||||||
|
*/
|
||||||
|
export async function getSentMailList(
|
||||||
|
query: SentMailListQuery = {}
|
||||||
|
): Promise<SentMailListResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (query.page) params.append('page', query.page.toString());
|
||||||
|
if (query.limit) params.append('limit', query.limit.toString());
|
||||||
|
if (query.searchTerm) params.append('searchTerm', query.searchTerm);
|
||||||
|
if (query.status && query.status !== 'all') params.append('status', query.status);
|
||||||
|
if (query.accountId) params.append('accountId', query.accountId);
|
||||||
|
if (query.startDate) params.append('startDate', query.startDate);
|
||||||
|
if (query.endDate) params.append('endDate', query.endDate);
|
||||||
|
if (query.sortBy) params.append('sortBy', query.sortBy);
|
||||||
|
if (query.sortOrder) params.append('sortOrder', query.sortOrder);
|
||||||
|
|
||||||
|
return fetchApi(`/mail/sent?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 발송 이력 상세 조회
|
||||||
|
*/
|
||||||
|
export async function getSentMailById(id: string): Promise<SentMailHistory> {
|
||||||
|
return fetchApi(`/mail/sent/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 발송 이력 삭제
|
||||||
|
*/
|
||||||
|
export async function deleteSentMail(id: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
return fetchApi(`/mail/sent/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메일 발송 통계 조회
|
||||||
|
*/
|
||||||
|
export async function getMailStatistics(accountId?: string): Promise<MailStatistics> {
|
||||||
|
const params = accountId ? `?accountId=${accountId}` : '';
|
||||||
|
return fetchApi(`/mail/sent/statistics${params}`);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue