메일관리 콘솔로그 주석처리 세이브

This commit is contained in:
leeheejin 2025-10-02 18:22:58 +09:00
parent bf58e0c878
commit b4c5be1f17
28 changed files with 3081 additions and 460 deletions

View File

@ -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를 만들 수 있습니다!** 🎨✨

View File

@ -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": []
}

View File

@ -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": []
}

View File

@ -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": []
}

View File

@ -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": []
}

View File

@ -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": []
}

View File

@ -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": []
}

View File

@ -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": []
}

View File

@ -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": []
}

View File

@ -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",

View File

@ -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",

View File

@ -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);

View File

@ -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,
}));
};

View File

@ -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) {

View File

@ -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();

View File

@ -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));

View File

@ -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;

View File

@ -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,
}; };

View File

@ -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();

View File

@ -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();

View File

@ -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;
}

View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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>
);
}

View File

@ -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"

View File

@ -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}`);
}