From b4c5be1f17dbbb4e47e04cfe72867edc83c47adf Mon Sep 17 00:00:00 2001
From: leeheejin
Date: Thu, 2 Oct 2025 18:22:58 +0900
Subject: [PATCH] =?UTF-8?q?=EB=A9=94=EC=9D=BC=EA=B4=80=EB=A6=AC=20?=
=?UTF-8?q?=EC=BD=98=EC=86=94=EB=A1=9C=EA=B7=B8=20=EC=A3=BC=EC=84=9D?=
=?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=84=B8=EC=9D=B4=EB=B8=8C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
UI_개선사항_문서.md | 196 +++
.../2d848b19-26e1-45ad-8e2c-9205f1f01c87.json | 41 +
.../37fce6a0-2301-431b-b573-82bdab9b8008.json | 41 +
.../a1ca39ad-4467-44e0-963a-fba5037c8896.json | 41 +
.../a3a9aab1-4334-46bd-bf50-b867305f66c0.json | 41 +
.../b1d8f458-076c-4c44-982e-d2f46dcd4b03.json | 48 +
.../b75d0b2b-7d8a-461b-b854-2bebdef959e8.json | 41 +
.../ccdd8961-1b3f-4b88-b838-51d6ed8f1601.json | 41 +
.../ee0d162c-48ad-4c00-8c56-ade80be4503f.json | 41 +
backend-node/package-lock.json | 53 +-
backend-node/package.json | 1 +
backend-node/src/app.ts | 2 +
backend-node/src/config/multerConfig.ts | 111 ++
.../controllers/mailSendSimpleController.ts | 61 +-
.../controllers/mailSentHistoryController.ts | 140 +++
.../src/routes/mailSendSimpleRoutes.ts | 9 +-
.../src/routes/mailSentHistoryRoutes.ts | 23 +
.../src/services/mailReceiveBasicService.ts | 181 ++-
.../src/services/mailSendSimpleService.ts | 262 +++--
.../src/services/mailSentHistoryService.ts | 232 ++++
backend-node/src/types/mailSentHistory.ts | 63 +
.../app/(main)/admin/mail/accounts/page.tsx | 12 +-
.../app/(main)/admin/mail/dashboard/page.tsx | 28 +-
.../app/(main)/admin/mail/receive/page.tsx | 11 +
frontend/app/(main)/admin/mail/send/page.tsx | 1048 ++++++++++++-----
frontend/app/(main)/admin/mail/sent/page.tsx | 616 ++++++++++
.../app/(main)/admin/mail/templates/page.tsx | 12 +-
frontend/lib/api/mail.ts | 145 ++-
28 files changed, 3081 insertions(+), 460 deletions(-)
create mode 100644 backend-node/data/mail-sent/2d848b19-26e1-45ad-8e2c-9205f1f01c87.json
create mode 100644 backend-node/data/mail-sent/37fce6a0-2301-431b-b573-82bdab9b8008.json
create mode 100644 backend-node/data/mail-sent/a1ca39ad-4467-44e0-963a-fba5037c8896.json
create mode 100644 backend-node/data/mail-sent/a3a9aab1-4334-46bd-bf50-b867305f66c0.json
create mode 100644 backend-node/data/mail-sent/b1d8f458-076c-4c44-982e-d2f46dcd4b03.json
create mode 100644 backend-node/data/mail-sent/b75d0b2b-7d8a-461b-b854-2bebdef959e8.json
create mode 100644 backend-node/data/mail-sent/ccdd8961-1b3f-4b88-b838-51d6ed8f1601.json
create mode 100644 backend-node/data/mail-sent/ee0d162c-48ad-4c00-8c56-ade80be4503f.json
create mode 100644 backend-node/src/config/multerConfig.ts
create mode 100644 backend-node/src/controllers/mailSentHistoryController.ts
create mode 100644 backend-node/src/routes/mailSentHistoryRoutes.ts
create mode 100644 backend-node/src/services/mailSentHistoryService.ts
create mode 100644 backend-node/src/types/mailSentHistory.ts
create mode 100644 frontend/app/(main)/admin/mail/sent/page.tsx
diff --git a/UI_개선사항_문서.md b/UI_개선사항_문서.md
index da991296..b6bb785f 100644
--- a/UI_개선사항_문서.md
+++ b/UI_개선사항_문서.md
@@ -601,4 +601,200 @@ export default function EmptyStatePage() {
---
+## 📧 메일 관리 시스템 UI 개선사항
+
+### 최근 업데이트 (2025-01-02)
+
+#### 1. 메일 발송 페이지 헤더 개선
+**변경 전:**
+```tsx
+
+```
+
+**변경 후 (표준 헤더 카드 적용):**
+```tsx
+
+
+ 메일 발송
+
+ 템플릿을 선택하거나 직접 작성하여 메일을 발송하세요
+
+
+
+```
+
+**개선 사항:**
+- ✅ 불필요한 아이콘 제거 (종이비행기)
+- ✅ 표준 Card 컴포넌트 사용으로 통일감 향상
+- ✅ 다른 페이지와 동일한 헤더 스타일 적용
+
+#### 2. 메일 내용 입력 개선
+**변경 전:**
+```tsx
+
+```
+
+**변경 후:**
+```tsx
+
+
+ 💡 일반 텍스트로 작성하면 자동으로 메일 형식으로 변환됩니다
+
+```
+
+**개선 사항:**
+- ✅ HTML 지식 없이도 사용 가능
+- ✅ 일반 텍스트 입력 후 자동 HTML 변환
+- ✅ 사용자 친화적인 안내 메시지
+
+#### 3. CC/BCC 기능 추가
+**구현 내용:**
+```tsx
+{/* To 태그 입력 */}
+
+
+{/* CC 태그 입력 */}
+
+
+{/* BCC 태그 입력 */}
+
+```
+
+**특징:**
+- ✅ 이메일 주소를 태그 형태로 시각화
+- ✅ 쉼표로 구분하여 입력 가능
+- ✅ 개별 삭제 가능
+
+#### 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
+// 이메일 주소를 시각적으로 표시
+
+ {tags.map((tag, index) => (
+
+
+ {tag}
+ removeTag(index)}>
+
+
+
+ ))}
+
+```
+
+#### 파일 첨부 영역 (예정)
+```tsx
+
+
+
+
+
+ 파일을 드래그하거나 클릭하여 선택하세요
+
+
+ 최대 5개, 각 10MB 이하
+
+
+
+```
+
+#### 발송 성공 토스트
+```tsx
+
+
+
+
+
메일이 발송되었습니다
+
+ {to.length}명에게 전송 완료
+
+
+
+
+```
+
+---
+
**이 가이드를 따라 개발하면 일관되고 아름다운 UI를 만들 수 있습니다!** 🎨✨
diff --git a/backend-node/data/mail-sent/2d848b19-26e1-45ad-8e2c-9205f1f01c87.json b/backend-node/data/mail-sent/2d848b19-26e1-45ad-8e2c-9205f1f01c87.json
new file mode 100644
index 00000000..a6fed281
--- /dev/null
+++ b/backend-node/data/mail-sent/2d848b19-26e1-45ad-8e2c-9205f1f01c87.json
@@ -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 \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": []
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/37fce6a0-2301-431b-b573-82bdab9b8008.json b/backend-node/data/mail-sent/37fce6a0-2301-431b-b573-82bdab9b8008.json
new file mode 100644
index 00000000..d70b6897
--- /dev/null
+++ b/backend-node/data/mail-sent/37fce6a0-2301-431b-b573-82bdab9b8008.json
@@ -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 \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": []
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/a1ca39ad-4467-44e0-963a-fba5037c8896.json b/backend-node/data/mail-sent/a1ca39ad-4467-44e0-963a-fba5037c8896.json
new file mode 100644
index 00000000..31492a08
--- /dev/null
+++ b/backend-node/data/mail-sent/a1ca39ad-4467-44e0-963a-fba5037c8896.json
@@ -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 \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": "",
+ "accepted": [
+ "zian9227@naver.com"
+ ],
+ "rejected": []
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/a3a9aab1-4334-46bd-bf50-b867305f66c0.json b/backend-node/data/mail-sent/a3a9aab1-4334-46bd-bf50-b867305f66c0.json
new file mode 100644
index 00000000..1435f837
--- /dev/null
+++ b/backend-node/data/mail-sent/a3a9aab1-4334-46bd-bf50-b867305f66c0.json
@@ -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 \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": []
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/b1d8f458-076c-4c44-982e-d2f46dcd4b03.json b/backend-node/data/mail-sent/b1d8f458-076c-4c44-982e-d2f46dcd4b03.json
new file mode 100644
index 00000000..8f8d5059
--- /dev/null
+++ b/backend-node/data/mail-sent/b1d8f458-076c-4c44-982e-d2f46dcd4b03.json
@@ -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 \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": "",
+ "accepted": [
+ "zian9227@naver.com"
+ ],
+ "rejected": []
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/b75d0b2b-7d8a-461b-b854-2bebdef959e8.json b/backend-node/data/mail-sent/b75d0b2b-7d8a-461b-b854-2bebdef959e8.json
new file mode 100644
index 00000000..dbec91a5
--- /dev/null
+++ b/backend-node/data/mail-sent/b75d0b2b-7d8a-461b-b854-2bebdef959e8.json
@@ -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 \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": []
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/ccdd8961-1b3f-4b88-b838-51d6ed8f1601.json b/backend-node/data/mail-sent/ccdd8961-1b3f-4b88-b838-51d6ed8f1601.json
new file mode 100644
index 00000000..d2d4c424
--- /dev/null
+++ b/backend-node/data/mail-sent/ccdd8961-1b3f-4b88-b838-51d6ed8f1601.json
@@ -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 \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": []
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/ee0d162c-48ad-4c00-8c56-ade80be4503f.json b/backend-node/data/mail-sent/ee0d162c-48ad-4c00-8c56-ade80be4503f.json
new file mode 100644
index 00000000..45c6a1eb
--- /dev/null
+++ b/backend-node/data/mail-sent/ee0d162c-48ad-4c00-8c56-ade80be4503f.json
@@ -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 \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": []
+}
\ No newline at end of file
diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json
index 291847f8..481ee282 100644
--- a/backend-node/package-lock.json
+++ b/backend-node/package-lock.json
@@ -18,6 +18,7 @@
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
+ "iconv-lite": "^0.7.0",
"imap": "^0.8.19",
"joi": "^17.11.0",
"jsonwebtoken": "^9.0.2",
@@ -4237,6 +4238,18 @@
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -6365,15 +6378,19 @@
}
},
"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==",
+ "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"
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
}
},
"node_modules/ieee754": {
@@ -8026,22 +8043,6 @@
"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": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz",
@@ -8936,6 +8937,18 @@
"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": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
diff --git a/backend-node/package.json b/backend-node/package.json
index 28829607..4ba53021 100644
--- a/backend-node/package.json
+++ b/backend-node/package.json
@@ -32,6 +32,7 @@
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
+ "iconv-lite": "^0.7.0",
"imap": "^0.8.19",
"joi": "^17.11.0",
"jsonwebtoken": "^9.0.2",
diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts
index 608abb51..c5f793b7 100644
--- a/backend-node/src/app.ts
+++ b/backend-node/src/app.ts
@@ -32,6 +32,7 @@ import mailTemplateFileRoutes from "./routes/mailTemplateFileRoutes";
import mailAccountFileRoutes from "./routes/mailAccountFileRoutes";
import mailSendSimpleRoutes from "./routes/mailSendSimpleRoutes";
import mailReceiveBasicRoutes from "./routes/mailReceiveBasicRoutes";
+import mailSentHistoryRoutes from "./routes/mailSentHistoryRoutes";
import dataRoutes from "./routes/dataRoutes";
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
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/send", mailSendSimpleRoutes); // 메일 발송
app.use("/api/mail/receive", mailReceiveBasicRoutes); // 메일 수신
+app.use("/api/mail/sent", mailSentHistoryRoutes); // 발송 이력
app.use("/api/screen", screenStandardRoutes);
app.use("/api/data", dataRoutes);
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
diff --git a/backend-node/src/config/multerConfig.ts b/backend-node/src/config/multerConfig.ts
new file mode 100644
index 00000000..e1389030
--- /dev/null
+++ b/backend-node/src/config/multerConfig.ts
@@ -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,
+ }));
+};
+
diff --git a/backend-node/src/controllers/mailSendSimpleController.ts b/backend-node/src/controllers/mailSendSimpleController.ts
index 15a1bea0..dc92f7cf 100644
--- a/backend-node/src/controllers/mailSendSimpleController.ts
+++ b/backend-node/src/controllers/mailSendSimpleController.ts
@@ -3,12 +3,28 @@ import { mailSendSimpleService } from '../services/mailSendSimpleService';
export class MailSendSimpleController {
/**
- * 메일 발송 (단건 또는 소규모)
+ * 메일 발송 (단건 또는 소규모) - 첨부파일 지원
*/
async sendMail(req: Request, res: Response) {
try {
- console.log('📧 메일 발송 요청 수신:', { accountId: req.body.accountId, to: req.body.to, subject: req.body.subject });
- const { accountId, templateId, to, subject, variables, customHtml } = req.body;
+ console.log('📧 메일 발송 요청 수신:', {
+ 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) {
@@ -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({
accountId,
templateId,
to,
+ cc,
+ bcc,
subject,
variables,
customHtml,
+ attachments: attachments.length > 0 ? attachments : undefined,
});
if (result.success) {
diff --git a/backend-node/src/controllers/mailSentHistoryController.ts b/backend-node/src/controllers/mailSentHistoryController.ts
new file mode 100644
index 00000000..129d72a7
--- /dev/null
+++ b/backend-node/src/controllers/mailSentHistoryController.ts
@@ -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();
+
diff --git a/backend-node/src/routes/mailSendSimpleRoutes.ts b/backend-node/src/routes/mailSendSimpleRoutes.ts
index 726a220c..f354957c 100644
--- a/backend-node/src/routes/mailSendSimpleRoutes.ts
+++ b/backend-node/src/routes/mailSendSimpleRoutes.ts
@@ -1,14 +1,19 @@
import { Router } from 'express';
import { mailSendSimpleController } from '../controllers/mailSendSimpleController';
import { authenticateToken } from '../middleware/authMiddleware';
+import { uploadMailAttachment } from '../config/multerConfig';
const router = Router();
// 모든 메일 발송 라우트에 인증 미들웨어 적용
router.use(authenticateToken);
-// POST /api/mail/send/simple - 메일 발송
-router.post('/simple', (req, res) => mailSendSimpleController.sendMail(req, res));
+// POST /api/mail/send/simple - 메일 발송 (첨부파일 지원)
+router.post(
+ '/simple',
+ uploadMailAttachment.array('attachments', 5), // 최대 5개 파일
+ (req, res) => mailSendSimpleController.sendMail(req, res)
+);
// POST /api/mail/send/test-connection - SMTP 연결 테스트
router.post('/test-connection', (req, res) => mailSendSimpleController.testConnection(req, res));
diff --git a/backend-node/src/routes/mailSentHistoryRoutes.ts b/backend-node/src/routes/mailSentHistoryRoutes.ts
new file mode 100644
index 00000000..2f4c6f98
--- /dev/null
+++ b/backend-node/src/routes/mailSentHistoryRoutes.ts
@@ -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;
+
diff --git a/backend-node/src/services/mailReceiveBasicService.ts b/backend-node/src/services/mailReceiveBasicService.ts
index 9208a852..a2ccaa72 100644
--- a/backend-node/src/services/mailReceiveBasicService.ts
+++ b/backend-node/src/services/mailReceiveBasicService.ts
@@ -3,9 +3,11 @@
* IMAP 연결 및 메일 목록 조회
*/
-import * as Imap from 'imap';
+// CommonJS 모듈이므로 require 사용
+const Imap = require('imap');
import { simpleParser } from 'mailparser';
import { mailAccountFileService } from './mailAccountFileService';
+import { encryptionService } from './encryptionService';
import fs from 'fs/promises';
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 연결 생성
*/
@@ -80,27 +96,47 @@ export class MailReceiveBasicService {
throw new Error('메일 계정을 찾을 수 없습니다.');
}
+ // 비밀번호 복호화
+ const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
+
+ // IMAP 설정
+ const accountAny = account as any;
const imapConfig: ImapConfig = {
user: account.email,
- password: account.smtpPassword, // 이미 복호화됨
- host: account.smtpHost,
- port: account.smtpPort === 587 ? 993 : account.smtpPort, // SMTP 587 -> IMAP 993
+ password: decryptedPassword,
+ host: accountAny.imapHost || account.smtpHost,
+ port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
tls: true,
};
+ console.log(`📧 IMAP 연결 시도 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, 이메일: ${imapConfig.user}`);
+
return new Promise((resolve, reject) => {
const imap = this.createImapConnection(imapConfig);
const mails: ReceivedMail[] = [];
+ // 30초 타임아웃 설정
+ const timeout = setTimeout(() => {
+ console.error('❌ IMAP 연결 타임아웃 (30초)');
+ imap.end();
+ reject(new Error('IMAP 연결 타임아웃'));
+ }, 30000);
+
imap.once('ready', () => {
+ console.log('✅ IMAP 연결 성공! INBOX 열기 시도...');
+ clearTimeout(timeout);
+
imap.openBox('INBOX', true, (err: any, box: any) => {
if (err) {
+ console.error('❌ INBOX 열기 실패:', err);
imap.end();
return reject(err);
}
+ console.log(`📬 INBOX 열림 - 전체 메일 수: ${box.messages.total}`);
const totalMessages = box.messages.total;
if (totalMessages === 0) {
+ console.log('📭 메일함이 비어있습니다');
imap.end();
return resolve([]);
}
@@ -109,15 +145,23 @@ export class MailReceiveBasicService {
const start = Math.max(1, totalMessages - limit + 1);
const end = totalMessages;
+ console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`);
const fetch = imap.seq.fetch(`${start}:${end}`, {
bodies: ['HEADER', 'TEXT'],
struct: true,
});
+ console.log(`📦 fetch 객체 생성 완료`);
+
+ let processedCount = 0;
+ const totalToProcess = end - start + 1;
+
fetch.on('message', (msg: any, seqno: any) => {
+ console.log(`📬 메일 #${seqno} 처리 시작`);
let header: string = '';
let body: string = '';
let attributes: any = null;
+ let bodiesReceived = 0;
msg.on('body', (stream: any, info: any) => {
let buffer = '';
@@ -130,6 +174,7 @@ export class MailReceiveBasicService {
} else {
body = buffer;
}
+ bodiesReceived++;
});
});
@@ -137,50 +182,88 @@ export class MailReceiveBasicService {
attributes = attrs;
});
- msg.once('end', async () => {
- try {
- const parsed = await simpleParser(header + '\r\n\r\n' + body);
+ msg.once('end', () => {
+ // body 데이터를 모두 받을 때까지 대기
+ const waitForBodies = setInterval(async () => {
+ if (bodiesReceived >= 2 || (header && body)) {
+ clearInterval(waitForBodies);
+
+ try {
+ const parsed = await simpleParser(header + '\r\n\r\n' + body);
- const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from;
- const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to;
+ const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from;
+ const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to;
- const mail: ReceivedMail = {
- id: `${accountId}-${seqno}`,
- 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,
- };
+ const mail: ReceivedMail = {
+ id: `${accountId}-${seqno}`,
+ 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);
- } catch (parseError) {
- console.error('메일 파싱 오류:', parseError);
- }
+ mails.push(mail);
+ console.log(`✓ 메일 #${seqno} 파싱 완료 (${mails.length}/${totalToProcess})`);
+ processedCount++;
+ } catch (parseError) {
+ console.error(`메일 #${seqno} 파싱 오류:`, parseError);
+ processedCount++;
+ }
+ }
+ }, 50);
});
});
fetch.once('error', (fetchErr: any) => {
+ console.error('❌ 메일 fetch 에러:', fetchErr);
imap.end();
reject(fetchErr);
});
fetch.once('end', () => {
- imap.end();
- // 최신 메일이 위로 오도록 정렬
- mails.sort((a, b) => b.date.getTime() - a.date.getTime());
- resolve(mails);
+ console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`);
+
+ // 모든 메일 처리가 완료될 때까지 대기
+ 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) => {
+ console.error('❌ IMAP 연결 에러:', imapErr.message || imapErr);
+ clearTimeout(timeout);
reject(imapErr);
});
+ imap.once('end', () => {
+ console.log('🔌 IMAP 연결 종료');
+ });
+
+ console.log('🔗 IMAP.connect() 호출...');
imap.connect();
});
}
@@ -206,11 +289,15 @@ export class MailReceiveBasicService {
throw new Error('메일 계정을 찾을 수 없습니다.');
}
+ // 비밀번호 복호화
+ const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
+
+ const accountAny = account as any;
const imapConfig: ImapConfig = {
user: account.email,
- password: account.smtpPassword,
- host: account.smtpHost,
- port: account.smtpPort === 587 ? 993 : account.smtpPort,
+ password: decryptedPassword,
+ host: accountAny.imapHost || account.smtpHost,
+ port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
tls: true,
};
@@ -302,11 +389,15 @@ export class MailReceiveBasicService {
throw new Error('메일 계정을 찾을 수 없습니다.');
}
+ // 비밀번호 복호화
+ const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
+
+ const accountAny = account as any;
const imapConfig: ImapConfig = {
user: account.email,
- password: account.smtpPassword,
- host: account.smtpHost,
- port: account.smtpPort === 587 ? 993 : account.smtpPort,
+ password: decryptedPassword,
+ host: accountAny.imapHost || account.smtpHost,
+ port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
tls: true,
};
@@ -352,13 +443,19 @@ export class MailReceiveBasicService {
throw new Error('메일 계정을 찾을 수 없습니다.');
}
+ // 비밀번호 복호화
+ const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
+ // console.log(`🔐 IMAP 테스트 - 이메일: ${account.email}, 비밀번호 길이: ${decryptedPassword.length}`);
+
+ const accountAny = account as any;
const imapConfig: ImapConfig = {
user: account.email,
- password: account.smtpPassword,
- host: account.smtpHost,
- port: account.smtpPort === 587 ? 993 : account.smtpPort,
+ password: decryptedPassword,
+ host: accountAny.imapHost || account.smtpHost,
+ port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
tls: true,
};
+ // console.log(`📧 IMAP 설정 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, TLS: ${imapConfig.tls}`);
return new Promise((resolve, reject) => {
const imap = this.createImapConnection(imapConfig);
@@ -408,11 +505,15 @@ export class MailReceiveBasicService {
throw new Error('메일 계정을 찾을 수 없습니다.');
}
+ // 비밀번호 복호화
+ const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
+
+ const accountAny = account as any;
const imapConfig: ImapConfig = {
user: account.email,
- password: account.smtpPassword,
- host: account.smtpHost,
- port: account.smtpPort === 587 ? 993 : account.smtpPort,
+ password: decryptedPassword,
+ host: accountAny.imapHost || account.smtpHost,
+ port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
tls: true,
};
diff --git a/backend-node/src/services/mailSendSimpleService.ts b/backend-node/src/services/mailSendSimpleService.ts
index 473f3959..5314c004 100644
--- a/backend-node/src/services/mailSendSimpleService.ts
+++ b/backend-node/src/services/mailSendSimpleService.ts
@@ -7,14 +7,22 @@ import nodemailer from 'nodemailer';
import { mailAccountFileService } from './mailAccountFileService';
import { mailTemplateFileService } from './mailTemplateFileService';
import { encryptionService } from './encryptionService';
+import { mailSentHistoryService } from './mailSentHistoryService';
export interface SendMailRequest {
accountId: string;
templateId?: string;
- to: string[]; // 수신자 이메일 배열
+ to: string[]; // 받는 사람
+ cc?: string[]; // 참조 (Carbon Copy)
+ bcc?: string[]; // 숨은참조 (Blind Carbon Copy)
subject: string;
variables?: Record; // 템플릿 변수 치환
customHtml?: string; // 템플릿 없이 직접 HTML 작성 시
+ attachments?: Array<{ // 첨부파일
+ filename: string;
+ path: string;
+ contentType?: string;
+ }>;
}
export interface SendMailResult {
@@ -30,6 +38,8 @@ class MailSendSimpleService {
* 단일 메일 발송 또는 소규모 발송
*/
async sendMail(request: SendMailRequest): Promise {
+ let htmlContent = ''; // 상위 스코프로 이동
+
try {
// 1. 계정 조회
const account = await mailAccountFileService.getAccountById(request.accountId);
@@ -43,7 +53,7 @@ class MailSendSimpleService {
}
// 3. HTML 생성 (템플릿 또는 커스텀)
- let htmlContent = request.customHtml || '';
+ htmlContent = request.customHtml || '';
if (!htmlContent && request.templateId) {
const template = await mailTemplateFileService.getTemplateById(request.templateId);
@@ -59,20 +69,20 @@ class MailSendSimpleService {
// 4. 비밀번호 복호화
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
- console.log('🔐 비밀번호 복호화 완료');
- console.log('🔐 암호화된 비밀번호 (일부):', account.smtpPassword.substring(0, 30) + '...');
- console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
+ // console.log('🔐 비밀번호 복호화 완료');
+ // console.log('🔐 암호화된 비밀번호 (일부):', account.smtpPassword.substring(0, 30) + '...');
+ // console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
// 5. SMTP 연결 생성
// 포트 465는 SSL/TLS를 사용해야 함
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
- console.log('📧 SMTP 연결 설정:', {
- host: account.smtpHost,
- port: account.smtpPort,
- secure: isSecure,
- user: account.smtpUsername,
- });
+ // console.log('📧 SMTP 연결 설정:', {
+ // host: account.smtpHost,
+ // port: account.smtpPort,
+ // secure: isSecure,
+ // user: account.smtpUsername,
+ // });
const transporter = nodemailer.createTransport({
host: account.smtpHost,
@@ -89,13 +99,60 @@ class MailSendSimpleService {
console.log('📧 메일 발송 시도 중...');
- // 6. 메일 발송
- const info = await transporter.sendMail({
+ // 6. 메일 발송 (CC, BCC, 첨부파일 지원)
+ const mailOptions: any = {
from: `"${account.name}" <${account.email}>`,
to: request.to.join(', '),
subject: this.replaceVariables(request.subject, request.variables),
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('✅ 메일 발송 성공:', {
messageId: info.messageId,
@@ -103,6 +160,43 @@ class MailSendSimpleService {
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 {
success: true,
messageId: info.messageId,
@@ -113,6 +207,52 @@ class MailSendSimpleService {
const err = error as Error;
console.error('❌ 메일 발송 실패:', err.message);
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 {
success: false,
error: err.message,
@@ -136,33 +276,24 @@ class MailSendSimpleService {
if (variables) {
content = this.replaceVariables(content, variables);
}
- html += `${content}
`;
+ html += `${content}
`;
break;
-
case 'button':
let buttonText = component.text || 'Button';
if (variables) {
buttonText = this.replaceVariables(buttonText, variables);
}
- html += `
- ${buttonText}
- `;
+ html += ``;
break;
-
case 'image':
- html += ` `;
+ html += `
+
+
`;
break;
-
case 'spacer':
- html += `
`;
+ html += `
`;
break;
}
});
@@ -174,10 +305,13 @@ class MailSendSimpleService {
/**
* 변수 치환
*/
- private replaceVariables(text: string, variables?: Record): string {
- if (!variables) return text;
+ private replaceVariables(
+ content: string,
+ variables?: Record
+ ): string {
+ if (!variables) return content;
- let result = text;
+ let result = content;
Object.entries(variables).forEach(([key, value]) => {
const regex = new RegExp(`\\{${key}\\}`, 'g');
result = result.replace(regex, value);
@@ -186,48 +320,30 @@ class MailSendSimpleService {
return result;
}
- /**
- * 스타일 객체를 CSS 문자열로 변환
- */
- private styleObjectToString(styles?: Record): 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 연결 테스트
*/
async testConnection(accountId: string): Promise<{ success: boolean; message: string }> {
try {
- console.log('🔌 SMTP 연결 테스트 시작:', accountId);
-
const account = await mailAccountFileService.getAccountById(accountId);
if (!account) {
- throw new Error('계정을 찾을 수 없습니다.');
+ return { success: false, message: '메일 계정을 찾을 수 없습니다.' };
}
// 비밀번호 복호화
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
- console.log('🔐 비밀번호 복호화 완료');
+ // console.log('🔐 테스트용 비밀번호 복호화 완료');
+ // console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
// 포트 465는 SSL/TLS를 사용해야 함
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
- console.log('🔌 SMTP 연결 설정:', {
- host: account.smtpHost,
- port: account.smtpPort,
- secure: isSecure,
- user: account.smtpUsername,
- });
+ // console.log('🧪 SMTP 연결 테스트 시작:', {
+ // host: account.smtpHost,
+ // port: account.smtpPort,
+ // secure: isSecure,
+ // user: account.smtpUsername,
+ // });
const transporter = nodemailer.createTransport({
host: account.smtpHost,
@@ -237,28 +353,22 @@ class MailSendSimpleService {
user: account.smtpUsername,
pass: decryptedPassword, // 복호화된 비밀번호 사용
},
- connectionTimeout: 10000, // 10초 타임아웃
+ // 테스트용 타임아웃 (10초)
+ connectionTimeout: 10000,
greetingTimeout: 10000,
});
- console.log('🔌 SMTP 연결 검증 중...');
+ // 연결 테스트
await transporter.verify();
- console.log('✅ SMTP 연결 검증 성공!');
-
- return {
- success: true,
- message: 'SMTP 연결 성공!',
- };
+
+ console.log('✅ SMTP 연결 테스트 성공');
+ return { success: true, message: 'SMTP 연결이 성공했습니다.' };
} catch (error) {
const err = error as Error;
- console.error('❌ SMTP 연결 실패:', err.message);
- return {
- success: false,
- message: `연결 실패: ${err.message}`,
- };
+ console.error('❌ SMTP 연결 테스트 실패:', err.message);
+ return { success: false, message: `SMTP 연결 실패: ${err.message}` };
}
}
}
-export const mailSendSimpleService = new MailSendSimpleService();
-
+export const mailSendSimpleService = new MailSendSimpleService();
\ No newline at end of file
diff --git a/backend-node/src/services/mailSentHistoryService.ts b/backend-node/src/services/mailSentHistoryService.ts
new file mode 100644
index 00000000..b69e5c77
--- /dev/null
+++ b/backend-node/src/services/mailSentHistoryService.ts
@@ -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): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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();
+
diff --git a/backend-node/src/types/mailSentHistory.ts b/backend-node/src/types/mailSentHistory.ts
new file mode 100644
index 00000000..1366acf4
--- /dev/null
+++ b/backend-node/src/types/mailSentHistory.ts
@@ -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;
+}
+
diff --git a/frontend/app/(main)/admin/mail/accounts/page.tsx b/frontend/app/(main)/admin/mail/accounts/page.tsx
index ca0cf0b9..c800cfb0 100644
--- a/frontend/app/(main)/admin/mail/accounts/page.tsx
+++ b/frontend/app/(main)/admin/mail/accounts/page.tsx
@@ -3,7 +3,8 @@
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
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 {
MailAccount,
getMailAccounts,
@@ -19,6 +20,7 @@ import MailAccountTable from "@/components/mail/MailAccountTable";
import ConfirmDeleteModal from "@/components/mail/ConfirmDeleteModal";
export default function MailAccountsPage() {
+ const router = useRouter();
const [accounts, setAccounts] = useState([]);
const [loading, setLoading] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -133,6 +135,14 @@ export default function MailAccountsPage() {
SMTP 메일 계정을 관리하고 발송 통계를 확인합니다
+ router.push('/admin/mail/dashboard')}
+ >
+
+ 대시보드
+
{
setLoading(true);
try {
- // 계정 수 (apiClient를 통해 토큰 포함)
+ // 계정 수
const accounts = await getMailAccounts();
- // 템플릿 수 (apiClient를 통해 토큰 포함)
+ // 템플릿 수
const templates = await getMailTemplates();
+ // 발송 통계
+ const mailStats = await getMailStatistics();
+
setStats({
totalAccounts: accounts.length,
totalTemplates: templates.length,
- sentToday: 0, // TODO: 실제 발송 통계 API 연동
- receivedToday: 0,
- sentThisMonth: 0,
- successRate: 0,
+ sentToday: mailStats.todayCount,
+ receivedToday: 0, // 수신함 기능은 별도
+ sentThisMonth: mailStats.thisMonthCount,
+ successRate: mailStats.successRate,
});
} catch (error) {
// console.error('통계 로드 실패:', error);
@@ -229,6 +232,17 @@ export default function MailDashboardPage() {
+
+
+
+
+
([]);
const [selectedAccountId, setSelectedAccountId] = useState("");
const [mails, setMails] = useState([]);
@@ -208,6 +211,14 @@ export default function MailReceivePage() {
+
router.push('/admin/mail/dashboard')}
+ >
+
+ 대시보드
+
([]);
const [templates, setTemplates] = useState([]);
const [loading, setLoading] = useState(false);
-
+ const [sending, setSending] = useState(false);
+
// 폼 상태
const [selectedAccountId, setSelectedAccountId] = useState("");
const [selectedTemplateId, setSelectedTemplateId] = useState("");
+ const [to, setTo] = useState([]);
+ const [cc, setCc] = useState([]);
+ const [bcc, setBcc] = useState([]);
+ const [toInput, setToInput] = useState("");
+ const [ccInput, setCcInput] = useState("");
+ const [bccInput, setBccInput] = useState("");
const [subject, setSubject] = useState("");
- const [recipients, setRecipients] = useState([""]);
+ const [customHtml, setCustomHtml] = useState("");
const [variables, setVariables] = useState>({});
-
- // UI 상태
- const [isSending, setIsSending] = useState(false);
const [showPreview, setShowPreview] = useState(false);
- const [sendResult, setSendResult] = useState<{
- success: boolean;
- message: string;
- } | null>(null);
+
+ // 템플릿 변수
+ const [templateVariables, setTemplateVariables] = useState([]);
+
+ // 첨부파일
+ const [attachments, setAttachments] = useState([]);
+ const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
- setLoading(true);
try {
+ setLoading(true);
const [accountsData, templatesData] = await Promise.all([
getMailAccounts(),
getMailTemplates(),
]);
- setAccounts(Array.isArray(accountsData) ? accountsData : []);
- setTemplates(Array.isArray(templatesData) ? templatesData : []);
-
- // 기본값 설정
- if (accountsData.length > 0 && !selectedAccountId) {
- setSelectedAccountId(accountsData[0].id);
- }
- } catch (error) {
- console.error('데이터 로드 실패:', error);
+ setAccounts(accountsData.filter((acc) => acc.status === "active"));
+ setTemplates(templatesData);
+ } catch (error: unknown) {
+ const err = error as Error;
+ toast({
+ title: "데이터 로드 실패",
+ description: err.message,
+ variant: "destructive",
+ });
} finally {
setLoading(false);
}
};
- const selectedTemplate = templates.find((t) => t.id === selectedTemplateId);
- const templateVariables = selectedTemplate
- ? extractTemplateVariables(selectedTemplate)
- : [];
-
- // 템플릿 선택 시 제목 자동 입력 및 변수 초기화
- useEffect(() => {
- if (selectedTemplate) {
- setSubject(selectedTemplate.subject);
+ // 템플릿 선택 시
+ const handleTemplateChange = (templateId: string) => {
+ // "__custom__"는 직접 작성을 의미
+ if (templateId === "__custom__") {
+ setSelectedTemplateId("");
+ setTemplateVariables([]);
+ setVariables({});
+ return;
+ }
+
+ setSelectedTemplateId(templateId);
+ const template = templates.find((t) => t.id === templateId);
+ if (template) {
+ setSubject(template.subject);
+ const vars = extractTemplateVariables(template);
+ setTemplateVariables(vars);
const initialVars: Record = {};
- templateVariables.forEach((varName) => {
- initialVars[varName] = "";
+ vars.forEach((v) => {
+ initialVars[v] = "";
});
setVariables(initialVars);
+ } else {
+ setTemplateVariables([]);
+ setVariables({});
}
- }, [selectedTemplateId]);
-
- const addRecipient = () => {
- setRecipients([...recipients, ""]);
};
- const removeRecipient = (index: number) => {
- setRecipients(recipients.filter((_, i) => i !== index));
+ // 이메일 태그 입력 처리 (쉼표, 엔터, 공백 시 추가)
+ const handleEmailInput = (
+ e: KeyboardEvent,
+ type: "to" | "cc" | "bcc"
+ ) => {
+ const input = type === "to" ? toInput : type === "cc" ? ccInput : bccInput;
+ const setInput =
+ type === "to" ? setToInput : type === "cc" ? setCcInput : setBccInput;
+ const emails = type === "to" ? to : type === "cc" ? cc : bcc;
+ const setEmails = type === "to" ? setTo : type === "cc" ? setCc : setBcc;
+
+ if (e.key === "Enter" || e.key === "," || e.key === " ") {
+ e.preventDefault();
+ const trimmedInput = input.trim().replace(/,$/, "");
+ if (trimmedInput && isValidEmail(trimmedInput)) {
+ if (!emails.includes(trimmedInput)) {
+ setEmails([...emails, trimmedInput]);
+ }
+ setInput("");
+ } else if (trimmedInput) {
+ toast({
+ title: "잘못된 이메일 형식",
+ description: `"${trimmedInput}"은(는) 올바른 이메일 주소가 아닙니다.`,
+ variant: "destructive",
+ });
+ }
+ }
};
- const updateRecipient = (index: number, value: string) => {
- const newRecipients = [...recipients];
- newRecipients[index] = value;
- setRecipients(newRecipients);
+ // 이메일 주소 유효성 검사
+ const isValidEmail = (email: string) => {
+ const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ return regex.test(email);
};
- const handleSend = async () => {
- // 유효성 검증
- const validRecipients = recipients.filter((email) => email.trim() !== "");
- if (validRecipients.length === 0) {
- alert("수신자 이메일을 입력하세요.");
+ // 이메일 태그 제거
+ const removeEmail = (email: string, type: "to" | "cc" | "bcc") => {
+ if (type === "to") {
+ setTo(to.filter((e) => e !== email));
+ } else if (type === "cc") {
+ setCc(cc.filter((e) => e !== email));
+ } else {
+ setBcc(bcc.filter((e) => e !== email));
+ }
+ };
+
+ // 텍스트를 HTML로 변환 (줄바꿈 처리)
+ const convertTextToHtml = (text: string) => {
+ // 줄바꿈을 로 변환하고 단락으로 감싸기
+ const paragraphs = text.split('\n\n').filter(p => p.trim());
+ const html = paragraphs
+ .map(p => `${p.replace(/\n/g, ' ')}
`)
+ .join('');
+
+ return `
+
+ ${html}
+
+ `;
+ };
+
+ // 메일 발송
+ const handleSendMail = async () => {
+ if (!selectedAccountId) {
+ toast({
+ title: "계정 선택 필요",
+ description: "발송할 메일 계정을 선택해주세요.",
+ variant: "destructive",
+ });
return;
}
- if (!selectedAccountId) {
- alert("발송 계정을 선택하세요.");
+ if (to.length === 0) {
+ toast({
+ title: "수신자 필요",
+ description: "받는 사람을 1명 이상 입력해주세요.",
+ variant: "destructive",
+ });
return;
}
if (!subject.trim()) {
- alert("메일 제목을 입력하세요.");
+ toast({
+ title: "제목 필요",
+ description: "메일 제목을 입력해주세요.",
+ variant: "destructive",
+ });
return;
}
- if (!selectedTemplateId) {
- alert("템플릿을 선택하세요.");
+ if (!selectedTemplateId && !customHtml.trim()) {
+ toast({
+ title: "내용 필요",
+ description: "템플릿을 선택하거나 메일 내용을 입력해주세요.",
+ variant: "destructive",
+ });
return;
}
- setIsSending(true);
- setSendResult(null);
-
try {
- const result = await sendMail({
- accountId: selectedAccountId,
- templateId: selectedTemplateId,
- to: validRecipients,
- subject,
- variables,
+ setSending(true);
+
+ // 텍스트를 HTML로 자동 변환
+ const htmlContent = customHtml ? convertTextToHtml(customHtml) : undefined;
+
+ // FormData 생성 (파일 첨부 지원)
+ const formData = new FormData();
+ formData.append("accountId", selectedAccountId);
+ if (selectedTemplateId) {
+ formData.append("templateId", selectedTemplateId);
+ }
+ formData.append("to", JSON.stringify(to));
+ if (cc.length > 0) {
+ formData.append("cc", JSON.stringify(cc));
+ }
+ if (bcc.length > 0) {
+ formData.append("bcc", JSON.stringify(bcc));
+ }
+ formData.append("subject", subject);
+ if (variables && Object.keys(variables).length > 0) {
+ formData.append("variables", JSON.stringify(variables));
+ }
+ if (htmlContent) {
+ formData.append("customHtml", htmlContent);
+ }
+
+ // 첨부파일 추가 (한글 파일명 처리)
+ attachments.forEach((file) => {
+ formData.append("attachments", file);
+ });
+
+ // 원본 파일명을 JSON으로 전송 (한글 파일명 보존)
+ if (attachments.length > 0) {
+ const originalFileNames = attachments.map(file => {
+ // 파일명 정규화 (NFD → NFC)
+ const normalizedName = file.name.normalize('NFC');
+ console.log('📎 파일명 정규화:', file.name, '->', normalizedName);
+ return normalizedName;
+ });
+ formData.append("fileNames", JSON.stringify(originalFileNames));
+ console.log('📎 전송할 정규화된 파일명들:', originalFileNames);
+ }
+
+ // API 호출 (FormData 전송)
+ const authToken = localStorage.getItem("authToken");
+ if (!authToken) {
+ throw new Error("인증 토큰이 없습니다. 다시 로그인해주세요.");
+ }
+
+ const response = await fetch("/api/mail/send/simple", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ },
+ body: formData,
});
- setSendResult({
- success: true,
- message: `${result.accepted?.length || 0}개 발송 성공`,
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.message || "메일 발송 실패");
+ }
+
+ // 성공 토스트
+ toast({
+ title: (
+
+
+ 메일 발송 완료!
+
+ ) as any,
+ description: `${to.length}명${cc.length > 0 ? ` (참조 ${cc.length}명)` : ""}${bcc.length > 0 ? ` (숨은참조 ${bcc.length}명)` : ""}${attachments.length > 0 ? ` (첨부파일 ${attachments.length}개)` : ""}에게 메일이 성공적으로 발송되었습니다.`,
+ className: "border-green-500 bg-green-50",
});
- // 성공 후 초기화
- setRecipients([""]);
+ // 폼 초기화
+ setTo([]);
+ setCc([]);
+ setBcc([]);
+ setToInput("");
+ setCcInput("");
+ setBccInput("");
+ setSubject("");
+ setCustomHtml("");
setVariables({});
- } catch (error) {
- setSendResult({
- success: false,
- message: error instanceof Error ? error.message : "발송 실패",
+ setSelectedTemplateId("");
+ setAttachments([]);
+ } catch (error: unknown) {
+ const err = error as Error;
+ toast({
+ title: (
+
+ ) as any,
+ description: err.message || "메일 발송 중 오류가 발생했습니다.",
+ variant: "destructive",
});
} finally {
- setIsSending(false);
+ setSending(false);
}
};
- const previewHtml = selectedTemplate
- ? renderTemplateToHtml(selectedTemplate, variables)
- : "";
+ // 파일 첨부 관련 함수
+ const handleFileSelect = (e: React.ChangeEvent) => {
+ const files = Array.from(e.target.files || []);
+ addFiles(files);
+ // input 초기화
+ e.target.value = "";
+ };
+
+ const handleDragOver = (e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragging(true);
+ };
+
+ const handleDragLeave = (e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragging(false);
+ };
+
+ const handleDrop = (e: React.DragEvent) => {
+ e.preventDefault();
+ setIsDragging(false);
+ const files = Array.from(e.dataTransfer.files);
+ addFiles(files);
+ };
+
+ const addFiles = (files: File[]) => {
+ // 파일 검증
+ const validFiles = files.filter((file) => {
+ // 파일 크기 제한 (10MB)
+ if (file.size > 10 * 1024 * 1024) {
+ toast({
+ title: "파일 크기 초과",
+ description: `${file.name}은(는) 10MB를 초과합니다.`,
+ variant: "destructive",
+ });
+ return false;
+ }
+
+ // 위험한 확장자 차단
+ const dangerousExtensions = [".exe", ".bat", ".cmd", ".sh", ".ps1", ".msi"];
+ const extension = file.name.toLowerCase().substring(file.name.lastIndexOf("."));
+ if (dangerousExtensions.includes(extension)) {
+ toast({
+ title: "허용되지 않는 파일 형식",
+ description: `${extension} 파일은 첨부할 수 없습니다.`,
+ variant: "destructive",
+ });
+ return false;
+ }
+
+ return true;
+ });
+
+ // 최대 5개 제한
+ const totalFiles = attachments.length + validFiles.length;
+ if (totalFiles > 5) {
+ toast({
+ title: "파일 개수 초과",
+ description: "최대 5개까지만 첨부할 수 있습니다.",
+ variant: "destructive",
+ });
+ return;
+ }
+
+ setAttachments([...attachments, ...validFiles]);
+ };
+
+ const removeFile = (index: number) => {
+ setAttachments(attachments.filter((_, i) => i !== index));
+ };
+
+ const formatFileSize = (bytes: number) => {
+ if (bytes === 0) return "0 Bytes";
+ const k = 1024;
+ const sizes = ["Bytes", "KB", "MB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i];
+ };
+
+ // 미리보기
+ const handlePreview = () => {
+ setShowPreview(!showPreview);
+ };
+
+ const getPreviewHtml = () => {
+ if (selectedTemplateId) {
+ const template = templates.find((t) => t.id === selectedTemplateId);
+ if (template) {
+ return renderTemplateToHtml(template, variables);
+ }
+ }
+ // 일반 텍스트를 HTML로 변환하여 미리보기
+ return customHtml ? convertTextToHtml(customHtml) : "";
+ };
if (loading) {
return (
-
-
+
+
);
}
return (
-
-
- {/* 페이지 제목 */}
-
-
메일 발송
-
템플릿을 선택하여 메일을 발송합니다
+
+ {/* 헤더 */}
+
+
+
메일 발송
+
템플릿을 선택하거나 직접 작성하여 메일을 발송하세요
+
router.push('/admin/mail/dashboard')}
+ >
+
+ 대시보드
+
+
- {/* 메인 폼 */}
-
- {/* 왼쪽: 발송 설정 */}
-
-
-
-
-
- 발송 설정
-
-
-
- {/* 발송 계정 선택 */}
-
-
- 발송 계정 *
-
- setSelectedAccountId(e.target.value)}
- className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
- >
- 계정 선택
- {accounts
- .filter((acc) => acc.status === "active")
- .map((account) => (
-
- {account.name} ({account.email})
-
- ))}
-
-
-
- {/* 템플릿 선택 */}
-
-
- 템플릿 *
-
-
setSelectedTemplateId(e.target.value)}
- className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
- >
- 템플릿 선택
- {templates.map((template) => (
-
- {template.name}
-
+
+ {/* 메일 작성 폼 */}
+
+ {/* 발송 설정 */}
+
+
+
+
+ 발송 설정
+
+
+
+ {/* 발송 계정 선택 */}
+
+ 발송 계정 *
+
+
+
+
+
+ {accounts.map((account) => (
+
+ {account.name} ({account.email})
+
))}
-
-
+
+
+
- {/* 메일 제목 */}
-
-
- 메일 제목 *
-
- setSubject(e.target.value)}
- placeholder="예: 환영합니다!"
- className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
- />
-
+ {/* 템플릿 선택 */}
+
+ 템플릿 (선택)
+
+
+
+
+
+ 직접 작성
+ {templates.map((template) => (
+
+ {template.name}
+
+ ))}
+
+
+
+
+
- {/* 수신자 */}
-
-
- 수신자 이메일 *
-
-
- {recipients.map((email, index) => (
-
-
updateRecipient(index, e.target.value)}
- placeholder="example@email.com"
- className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
- />
- {recipients.length > 1 && (
-
removeRecipient(index)}
- className="text-red-500 hover:text-red-600"
- >
-
-
- )}
+ {/* 수신자 */}
+
+
+
+
+ 수신자
+
+
+
+ {/* 받는 사람 */}
+
+
+
+ 받는 사람 *
+
+
+
+ {to.map((email) => (
+
+ {email}
+ removeEmail(email, "to")}
+ className="hover:bg-blue-200 rounded p-0.5"
+ >
+
+
+
+ ))}
+
setToInput(e.target.value)}
+ onKeyDown={(e) => handleEmailInput(e, "to")}
+ placeholder={to.length === 0 ? "이메일 주소 입력 후 엔터, 쉼표, 스페이스" : ""}
+ className="flex-1 outline-none min-w-[200px] text-sm"
+ />
+
+
+ 💡 이메일 주소를 입력하고 엔터, 쉼표(,), 스페이스를 눌러 추가하세요
+
+
+
+
+ {/* 참조 (CC) */}
+
+
+
+ 참조 (CC)
+
+
+
+ {cc.map((email) => (
+
+ {email}
+ removeEmail(email, "cc")}
+ className="hover:bg-green-200 rounded p-0.5"
+ >
+
+
+
+ ))}
+
setCcInput(e.target.value)}
+ onKeyDown={(e) => handleEmailInput(e, "cc")}
+ placeholder={cc.length === 0 ? "참조로 받을 이메일 주소" : ""}
+ className="flex-1 outline-none min-w-[200px] text-sm"
+ />
+
+
+ 다른 수신자에게도 공개됩니다
+
+
+
+
+ {/* 숨은참조 (BCC) */}
+
+
+
+ 숨은참조 (BCC)
+
+
+
+ {bcc.map((email) => (
+
+ {email}
+ removeEmail(email, "bcc")}
+ className="hover:bg-purple-200 rounded p-0.5"
+ >
+
+
+
+ ))}
+
setBccInput(e.target.value)}
+ onKeyDown={(e) => handleEmailInput(e, "bcc")}
+ placeholder={bcc.length === 0 ? "숨은참조로 받을 이메일 주소" : ""}
+ className="flex-1 outline-none min-w-[200px] text-sm"
+ />
+
+
+ 🔒 다른 수신자에게 보이지 않습니다 (모니터링용)
+
+
+
+
+
+
+ {/* 메일 내용 */}
+
+
+
+
+ 메일 내용
+
+
+
+ {/* 제목 */}
+
+ 제목 *
+ setSubject(e.target.value)}
+ placeholder="메일 제목을 입력하세요"
+ />
+
+
+ {/* 템플릿 변수 입력 */}
+ {templateVariables.length > 0 && (
+
+
템플릿 변수
+ {templateVariables.map((varName) => (
+
+
+ {varName}
+
+
+ setVariables({ ...variables, [varName]: e.target.value })
+ }
+ placeholder={`{${varName}} 변수 값`}
+ />
+
+ ))}
+
+ )}
+
+ {/* 직접 작성 */}
+ {!selectedTemplateId && (
+
+ )}
+
+
+
+ {/* 파일 첨부 */}
+
+
+
+
+ 파일 첨부
+
+
+
+ {/* 드래그 앤 드롭 영역 */}
+ document.getElementById("file-input")?.click()}
+ >
+
+
+
+ 파일을 드래그하거나 클릭하여 선택하세요
+
+
+ 최대 5개, 각 10MB 이하
+
+
+
+ {/* 첨부된 파일 목록 */}
+ {attachments.length > 0 && (
+
+
첨부된 파일 ({attachments.length})
+
+ {attachments.map((file, index) => (
+
+
+
+
+
+ {file.name}
+
+
+ {formatFileSize(file.size)}
+
+
+
+
removeFile(index)}
+ className="flex-shrink-0 text-red-500 hover:text-red-600 hover:bg-red-50"
+ >
+
+
))}
-
-
- 수신자 추가
-
+ )}
+
+
- {/* 템플릿 변수 */}
- {templateVariables.length > 0 && (
-
-
- 템플릿 변수
-
-
- {templateVariables.map((varName) => (
-
-
- {varName}
-
-
- setVariables({ ...variables, [varName]: e.target.value })
- }
- placeholder={`{${varName}}`}
- className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
- />
-
- ))}
-
-
- )}
-
-
-
- {/* 발송 버튼 */}
-
- setShowPreview(!showPreview)}
- variant="outline"
- className="flex-1"
- disabled={!selectedTemplateId}
- >
-
- 미리보기
-
-
- {isSending ? (
- <>
-
- 발송 중...
- >
- ) : (
- <>
-
- 발송
- >
- )}
-
-
-
- {/* 발송 결과 */}
- {sendResult && (
-
-
-
- {sendResult.success ? (
-
- ) : (
-
- )}
-
- {sendResult.message}
-
-
-
-
- )}
-
-
- {/* 오른쪽: 미리보기 */}
-
-
-
-
-
- 미리보기
-
-
-
- {showPreview && previewHtml ? (
-
- ) : (
-
-
-
- 템플릿을 선택하고
-
- 미리보기 버튼을 클릭하세요
-
-
- )}
-
-
+ {/* 발송 버튼 */}
+
+
+ {sending ? (
+ <>
+
+ 발송 중...
+ >
+ ) : (
+ <>
+
+ 메일 발송
+ >
+ )}
+
+
+
+ 미리보기
+
+
+ {/* 미리보기 패널 */}
+ {showPreview && (
+
+
+
+
+
+
+ 미리보기
+
+ setShowPreview(false)}
+ >
+
+
+
+
+
+
+
+
+ 받는 사람: {to.join(", ") || "-"}
+
+ {cc.length > 0 && (
+
+ 참조: {cc.join(", ")}
+
+ )}
+ {bcc.length > 0 && (
+
+ 숨은참조: {bcc.join(", ")}
+
+ )}
+
+ 제목: {subject || "-"}
+
+ {attachments.length > 0 && (
+
+
첨부파일: {attachments.length}개
+
+ {attachments.map((file, index) => (
+
+
+ {file.name}
+ ({formatFileSize(file.size)})
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+ )}
);
diff --git a/frontend/app/(main)/admin/mail/sent/page.tsx b/frontend/app/(main)/admin/mail/sent/page.tsx
new file mode 100644
index 00000000..9a96e0b8
--- /dev/null
+++ b/frontend/app/(main)/admin/mail/sent/page.tsx
@@ -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
([]);
+ const [accounts, setAccounts] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [selectedMail, setSelectedMail] = useState(null);
+
+ // 필터 및 페이징
+ const [searchTerm, setSearchTerm] = useState("");
+ const [filterStatus, setFilterStatus] = useState<'all' | 'success' | 'failed'>('all');
+ const [filterAccountId, setFilterAccountId] = useState('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 (
+
+
+
+ );
+ }
+
+ return (
+
+ {/* 헤더 */}
+
+
+
+
+ 보낸메일함
+
+
발송된 메일의 이력을 확인하고 관리하세요
+
+
router.push('/admin/mail/dashboard')}
+ >
+
+ 대시보드
+
+
+
+ {/* 필터 및 검색 */}
+
+
+
+
+ 필터 및 검색
+
+
+
+
+ {/* 검색 */}
+
+
검색
+
+ setSearchTerm(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
+ placeholder="제목 또는 받는사람 검색..."
+ />
+
+
+
+
+
+
+ {/* 상태 필터 */}
+
+ 상태
+ {
+ setFilterStatus(v);
+ setPage(1);
+ }}>
+
+
+
+
+ 전체
+ 성공
+ 실패
+
+
+
+
+ {/* 계정 필터 */}
+
+ 발송 계정
+ {
+ setFilterAccountId(v);
+ setPage(1);
+ }}>
+
+
+
+
+ 전체 계정
+ {accounts.map((account) => (
+
+ {account.name}
+
+ ))}
+
+
+
+
+
+
+
+ {
+ setSortBy('sentAt');
+ setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
+ }}
+ >
+
+ 날짜순 {sortBy === 'sentAt' && (sortOrder === 'asc' ? '↑' : '↓')}
+
+ {
+ setSortBy('subject');
+ setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
+ }}
+ >
+ 제목순 {sortBy === 'subject' && (sortOrder === 'asc' ? '↑' : '↓')}
+
+
+
+
+
+ 새로고침
+
+
+
+
+
+ {/* 메일 목록 */}
+
+
+
+ 발송 이력 ({total}건)
+
+
+
+ {mails.length === 0 ? (
+
+ ) : (
+
+ {mails.map((mail) => (
+
+
+
+ {mail.status === 'success' ? (
+
+ ) : (
+
+ )}
+
+ {mail.subject}
+
+ {mail.attachments && mail.attachments.length > 0 && (
+
+ )}
+
+
+
+
+ {mail.accountName}
+
+
+
+ 받는사람: {mail.to.length}명
+ {mail.cc && mail.cc.length > 0 && (
+ (참조 {mail.cc.length}명)
+ )}
+
+
+
+ {formatDate(mail.sentAt)}
+
+
+ {mail.status === 'failed' && mail.errorMessage && (
+
+ 오류: {mail.errorMessage}
+
+ )}
+
+
+ setSelectedMail(mail)}
+ >
+
+
+ handleDelete(mail.id)}
+ className="text-red-500 hover:text-red-600 hover:bg-red-50"
+ >
+
+
+
+
+ ))}
+
+ )}
+
+ {/* 페이징 */}
+ {totalPages > 1 && (
+
+
setPage((p) => Math.max(1, p - 1))}
+ disabled={page === 1}
+ >
+ 이전
+
+
+ {page} / {totalPages}
+
+
setPage((p) => Math.min(totalPages, p + 1))}
+ disabled={page === totalPages}
+ >
+ 다음
+
+
+ )}
+
+
+
+ {/* 상세보기 모달 */}
+
!open && setSelectedMail(null)}>
+
+
+
+
+ {selectedMail?.status === 'success' ? (
+
+ ) : (
+
+ )}
+ 발송 상세 정보
+
+ setSelectedMail(null)}
+ >
+
+
+
+
+
+ {selectedMail && (
+
+ {/* 발송 정보 */}
+
+
+ 발송 정보
+
+
+
+
+
발송 계정
+
+ {selectedMail.accountName} ({selectedMail.accountEmail})
+
+
+
+
발송 시간
+
+ {formatDate(selectedMail.sentAt)}
+
+
+
+
+
+
상태
+
+ {selectedMail.status === 'success' ? (
+
+ 발송 성공
+
+ ) : (
+
+ 발송 실패
+
+ )}
+
+
+
+ {selectedMail.messageId && (
+
+
메시지 ID
+
+ {selectedMail.messageId}
+
+
+ )}
+
+ {selectedMail.errorMessage && (
+
+
오류 메시지
+
+ {selectedMail.errorMessage}
+
+
+ )}
+
+
+
+ {/* 수신자 정보 */}
+
+
+ 수신자 정보
+
+
+
+
받는 사람
+
+ {selectedMail.to.map((email, i) => (
+
+ {email}
+
+ ))}
+
+
+
+ {selectedMail.cc && selectedMail.cc.length > 0 && (
+
+
참조 (CC)
+
+ {selectedMail.cc.map((email, i) => (
+
+ {email}
+
+ ))}
+
+
+ )}
+
+ {selectedMail.bcc && selectedMail.bcc.length > 0 && (
+
+
숨은참조 (BCC)
+
+ {selectedMail.bcc.map((email, i) => (
+
+ {email}
+
+ ))}
+
+
+ )}
+
+ {selectedMail.accepted && selectedMail.accepted.length > 0 && (
+
+
수락됨
+
+ {selectedMail.accepted.join(", ")}
+
+
+ )}
+
+ {selectedMail.rejected && selectedMail.rejected.length > 0 && (
+
+
거부됨
+
+ {selectedMail.rejected.join(", ")}
+
+
+ )}
+
+
+
+ {/* 메일 내용 */}
+
+
+ 메일 내용
+
+
+
+
제목
+
+ {selectedMail.subject}
+
+
+
+ {selectedMail.templateName && (
+
+
사용한 템플릿
+
+ {selectedMail.templateName}
+
+
+ )}
+
+
+
+
+
+ {/* 첨부파일 */}
+ {selectedMail.attachments && selectedMail.attachments.length > 0 && (
+
+
+
+
+ 첨부파일 ({selectedMail.attachments.length})
+
+
+
+
+ {selectedMail.attachments.map((file, i) => (
+
+
+
+
+
+ {file.originalName}
+
+
+ {formatFileSize(file.size)} • {file.mimetype}
+
+
+
+
+ ))}
+
+
+
+ )}
+
+ )}
+
+
+
+ );
+}
+
diff --git a/frontend/app/(main)/admin/mail/templates/page.tsx b/frontend/app/(main)/admin/mail/templates/page.tsx
index 6814af06..e4155b5c 100644
--- a/frontend/app/(main)/admin/mail/templates/page.tsx
+++ b/frontend/app/(main)/admin/mail/templates/page.tsx
@@ -3,7 +3,8 @@
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
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 {
MailTemplate,
getMailTemplates,
@@ -19,6 +20,7 @@ import MailTemplateEditorModal from "@/components/mail/MailTemplateEditorModal";
import ConfirmDeleteModal from "@/components/mail/ConfirmDeleteModal";
export default function MailTemplatesPage() {
+ const router = useRouter();
const [templates, setTemplates] = useState([]);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
@@ -137,6 +139,14 @@ export default function MailTemplatesPage() {
드래그 앤 드롭으로 메일 템플릿을 만들고 관리합니다
+ router.push('/admin/mail/dashboard')}
+ >
+
+ 대시보드
+
{}
export interface SendMailDto {
accountId: string;
templateId?: string;
- to: string[]; // 수신자 이메일 배열
+ to: string[]; // 받는 사람
+ cc?: string[]; // 참조 (Carbon Copy)
+ bcc?: string[]; // 숨은참조 (Blind Carbon Copy)
subject: string;
variables?: Record; // 템플릿 변수 치환
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 {
success: boolean;
messageId?: string;
@@ -96,7 +160,7 @@ async function fetchApi(
try {
const response = await apiClient({
- url: `/mail${endpoint}`,
+ url: endpoint, // `/mail` 접두사 제거 (apiClient는 이미 /api를 포함)
method,
data,
});
@@ -124,14 +188,14 @@ async function fetchApi(
* 전체 메일 계정 목록 조회
*/
export async function getMailAccounts(): Promise {
- return fetchApi('/accounts');
+ return fetchApi('/mail/accounts');
}
/**
* 특정 메일 계정 조회
*/
export async function getMailAccount(id: string): Promise {
- return fetchApi(`/accounts/${id}`);
+ return fetchApi(`/mail/accounts/${id}`);
}
/**
@@ -140,7 +204,7 @@ export async function getMailAccount(id: string): Promise {
export async function createMailAccount(
data: CreateMailAccountDto
): Promise {
- return fetchApi('/accounts', {
+ return fetchApi('/mail/accounts', {
method: 'POST',
data,
});
@@ -153,7 +217,7 @@ export async function updateMailAccount(
id: string,
data: UpdateMailAccountDto
): Promise {
- return fetchApi(`/accounts/${id}`, {
+ return fetchApi(`/mail/accounts/${id}`, {
method: 'PUT',
data,
});
@@ -163,7 +227,7 @@ export async function updateMailAccount(
* 메일 계정 삭제
*/
export async function deleteMailAccount(id: string): Promise<{ success: boolean }> {
- return fetchApi<{ success: boolean }>(`/accounts/${id}`, {
+ return fetchApi<{ success: boolean }>(`/mail/accounts/${id}`, {
method: 'DELETE',
});
}
@@ -172,7 +236,7 @@ export async function deleteMailAccount(id: string): Promise<{ success: boolean
* SMTP 연결 테스트
*/
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',
});
}
@@ -185,7 +249,7 @@ export async function testMailConnection(id: string): Promise<{
message: string;
}> {
return fetchApi<{ success: boolean; message: string }>(
- `/accounts/${id}/test-connection`,
+ `/mail/accounts/${id}/test-connection`,
{
method: 'POST',
}
@@ -200,14 +264,14 @@ export async function testMailConnection(id: string): Promise<{
* 전체 메일 템플릿 목록 조회
*/
export async function getMailTemplates(): Promise {
- return fetchApi('/templates-file');
+ return fetchApi('/mail/templates-file');
}
/**
* 특정 메일 템플릿 조회
*/
export async function getMailTemplate(id: string): Promise {
- return fetchApi(`/templates-file/${id}`);
+ return fetchApi(`/mail/templates-file/${id}`);
}
/**
@@ -216,7 +280,7 @@ export async function getMailTemplate(id: string): Promise {
export async function createMailTemplate(
data: CreateMailTemplateDto
): Promise {
- return fetchApi('/templates-file', {
+ return fetchApi('/mail/templates-file', {
method: 'POST',
data,
});
@@ -229,7 +293,7 @@ export async function updateMailTemplate(
id: string,
data: UpdateMailTemplateDto
): Promise {
- return fetchApi(`/templates-file/${id}`, {
+ return fetchApi(`/mail/templates-file/${id}`, {
method: 'PUT',
data,
});
@@ -239,7 +303,7 @@ export async function updateMailTemplate(
* 메일 템플릿 삭제
*/
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',
});
}
@@ -251,7 +315,7 @@ export async function previewMailTemplate(
id: string,
sampleData?: Record
): Promise<{ html: string }> {
- return fetchApi<{ html: string }>(`/templates-file/${id}/preview`, {
+ return fetchApi<{ html: string }>(`/mail/templates-file/${id}/preview`, {
method: 'POST',
data: { sampleData },
});
@@ -265,7 +329,7 @@ export async function previewMailTemplate(
* 메일 발송 (단건 또는 소규모 발송)
*/
export async function sendMail(data: SendMailDto): Promise {
- return fetchApi('/send/simple', {
+ return fetchApi('/mail/send/simple', {
method: 'POST',
data,
});
@@ -439,3 +503,52 @@ export async function testImapConnection(
method: 'POST',
});
}
+
+// ============================================
+// 발송 이력 API
+// ============================================
+
+/**
+ * 발송 이력 목록 조회
+ */
+export async function getSentMailList(
+ query: SentMailListQuery = {}
+): Promise {
+ 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 {
+ 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 {
+ const params = accountId ? `?accountId=${accountId}` : '';
+ return fetchApi(`/mail/sent/statistics${params}`);
+}