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/1e492bb1-d069-4242-8cbf-9829b8f6c7e6.json b/backend-node/data/mail-sent/1e492bb1-d069-4242-8cbf-9829b8f6c7e6.json
new file mode 100644
index 00000000..eccdc063
--- /dev/null
+++ b/backend-node/data/mail-sent/1e492bb1-d069-4242-8cbf-9829b8f6c7e6.json
@@ -0,0 +1,29 @@
+{
+ "id": "1e492bb1-d069-4242-8cbf-9829b8f6c7e6",
+ "sentAt": "2025-10-13T01:08:34.764Z",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [
+ "zian9227@naver.com"
+ ],
+ "subject": "제목 없음",
+ "htmlContent": "\n\n\n\n \n \n\n\n \n \n \n\n
\n
\n \n \n
\n\n \n \n\n",
+ "templateId": "template-1760315158387",
+ "templateName": "테스트2",
+ "attachments": [
+ {
+ "filename": "스크린샷 2025-10-13 오전 10.00.06.png",
+ "originalName": "스크린샷 2025-10-13 오전 10.00.06.png",
+ "size": 0,
+ "path": "/app/uploads/mail-attachments/1760317712416-622369845.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/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/34f7f149-ac97-442e-b595-02c990082f86.json b/backend-node/data/mail-sent/34f7f149-ac97-442e-b595-02c990082f86.json
new file mode 100644
index 00000000..46b0b1b8
--- /dev/null
+++ b/backend-node/data/mail-sent/34f7f149-ac97-442e-b595-02c990082f86.json
@@ -0,0 +1,29 @@
+{
+ "id": "34f7f149-ac97-442e-b595-02c990082f86",
+ "sentAt": "2025-10-13T01:04:08.560Z",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [
+ "zian9227@naver.com"
+ ],
+ "subject": "제목 없음",
+ "htmlContent": "\n\n\n\n \n \n\n\n \n \n \n\n
\n
\n \n \n
\n\n \n \n\n",
+ "templateId": "template-1760315158387",
+ "templateName": "테스트2",
+ "attachments": [
+ {
+ "filename": "한글.txt",
+ "originalName": "한글.txt",
+ "size": 0,
+ "path": "/app/uploads/mail-attachments/1760317447824-27488793.txt",
+ "mimetype": "text/plain"
+ }
+ ],
+ "status": "success",
+ "messageId": "<1d7caa77-12f1-a791-a230-162826cf03ea@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/3f72cbab-b60e-45e7-ac8d-7e441bc2b900.json b/backend-node/data/mail-sent/3f72cbab-b60e-45e7-ac8d-7e441bc2b900.json
new file mode 100644
index 00000000..05eb18c2
--- /dev/null
+++ b/backend-node/data/mail-sent/3f72cbab-b60e-45e7-ac8d-7e441bc2b900.json
@@ -0,0 +1,29 @@
+{
+ "id": "3f72cbab-b60e-45e7-ac8d-7e441bc2b900",
+ "sentAt": "2025-10-13T01:34:19.363Z",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [
+ "zian9227@naver.com"
+ ],
+ "subject": "테스트 템플릿이에용22",
+ "htmlContent": "\n\n\n\n \n \n\n\n \n \n \n안녕안녕하세요 이건 테스트용 템플릿입니다용22
\n
\n
여기에 뭘 또 입력해보세용[222] 안에 넣어도 돼요
\n \n \n
\n\n \n \n\n",
+ "templateId": "template-1760315158387",
+ "templateName": "테스트2",
+ "attachments": [
+ {
+ "filename": "blender study.docx",
+ "originalName": "blender study.docx",
+ "size": 0,
+ "path": "/app/uploads/mail-attachments/1760319257947-827879690.docx",
+ "mimetype": "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ }
+ ],
+ "status": "success",
+ "messageId": "<5b3d9f82-8531-f427-c7f7-9446b4f19da4@wace.me>",
+ "accepted": [
+ "zian9227@naver.com"
+ ],
+ "rejected": []
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/449d9951-51e8-4e81-ada4-e73aed8ff60e.json b/backend-node/data/mail-sent/449d9951-51e8-4e81-ada4-e73aed8ff60e.json
new file mode 100644
index 00000000..29ec634e
--- /dev/null
+++ b/backend-node/data/mail-sent/449d9951-51e8-4e81-ada4-e73aed8ff60e.json
@@ -0,0 +1,20 @@
+{
+ "id": "449d9951-51e8-4e81-ada4-e73aed8ff60e",
+ "sentAt": "2025-10-13T01:29:25.975Z",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [
+ "zian9227@naver.com"
+ ],
+ "subject": "테스트 템플릿이에용",
+ "htmlContent": "\n\n\n\n \n \n\n\n \n \n \n안녕안녕하세요 이건 테스트용 템플릿입니다용
\n
\n
안녕하세용 [뭘 넣은 결과 입니당]이안에 뭘 넣어보세용
여기에 뭘 또 입력해보세용[안에 뭘 넣은 결과입니다.] 안에 넣어도 돼요
\n \n \n
\n\n\n",
+ "templateId": "template-1760315158387",
+ "templateName": "테스트2",
+ "status": "success",
+ "messageId": "<5d52accb-777b-b6c2-aab7-1a2f7b7754ab@wace.me>",
+ "accepted": [
+ "zian9227@naver.com"
+ ],
+ "rejected": []
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/6dd3673a-f510-4ba9-9634-0b391f925230.json b/backend-node/data/mail-sent/6dd3673a-f510-4ba9-9634-0b391f925230.json
new file mode 100644
index 00000000..ee094c49
--- /dev/null
+++ b/backend-node/data/mail-sent/6dd3673a-f510-4ba9-9634-0b391f925230.json
@@ -0,0 +1,29 @@
+{
+ "id": "6dd3673a-f510-4ba9-9634-0b391f925230",
+ "sentAt": "2025-10-13T01:01:55.097Z",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [
+ "zian9227@naver.com"
+ ],
+ "subject": "테스트용입니당.",
+ "htmlContent": "\n\n\n\n \n \n\n\n \n \n \n \n \n \n\n
\n
\n \n \n
\n \n \n
\n\n \n \n\n",
+ "templateId": "template-1760315158387",
+ "templateName": "테스트2",
+ "attachments": [
+ {
+ "filename": "한글-분석.txt",
+ "originalName": "한글-분석.txt",
+ "size": 0,
+ "path": "/app/uploads/mail-attachments/1760317313641-761345104.txt",
+ "mimetype": "text/plain"
+ }
+ ],
+ "status": "success",
+ "messageId": "",
+ "accepted": [
+ "zian9227@naver.com"
+ ],
+ "rejected": []
+}
\ No newline at end of file
diff --git a/backend-node/data/mail-sent/9eab902e-f77b-424f-ada4-0ea8709b36bf.json b/backend-node/data/mail-sent/9eab902e-f77b-424f-ada4-0ea8709b36bf.json
new file mode 100644
index 00000000..ed2e4b14
--- /dev/null
+++ b/backend-node/data/mail-sent/9eab902e-f77b-424f-ada4-0ea8709b36bf.json
@@ -0,0 +1,29 @@
+{
+ "id": "9eab902e-f77b-424f-ada4-0ea8709b36bf",
+ "sentAt": "2025-10-13T00:53:55.193Z",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [
+ "zian9227@naver.com"
+ ],
+ "subject": "제목 없음",
+ "htmlContent": "
텍스트를 입력하세요...
\n
\n
텍스트를 입력하세요...
텍스트를 입력하세요...
\n
\n \r\n
\r\n
어덯게 나오는지 봅시다 추가메시지 영역이빈다.
\r\n
\r\n \n
\n
",
+ "templateId": "template-1760315158387",
+ "templateName": "테스트2",
+ "attachments": [
+ {
+ "filename": "한글.txt",
+ "originalName": "한글.txt",
+ "size": 0,
+ "path": "/app/uploads/mail-attachments/1760316833254-789302611.txt",
+ "mimetype": "text/plain"
+ }
+ ],
+ "status": "success",
+ "messageId": "<3d0bef10-2e58-fd63-b175-c1f499af0102@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/e2801ec2-6219-4c3c-83b4-8a6834569488.json b/backend-node/data/mail-sent/e2801ec2-6219-4c3c-83b4-8a6834569488.json
new file mode 100644
index 00000000..1a388699
--- /dev/null
+++ b/backend-node/data/mail-sent/e2801ec2-6219-4c3c-83b4-8a6834569488.json
@@ -0,0 +1,29 @@
+{
+ "id": "e2801ec2-6219-4c3c-83b4-8a6834569488",
+ "sentAt": "2025-10-13T00:59:46.729Z",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [
+ "zian9227@naver.com"
+ ],
+ "subject": "제목 없음",
+ "htmlContent": "
텍스트 영역 1
\n
\n
텍스트 영역2
텍스트 영역3
\n
\n
",
+ "templateId": "template-1760315158387",
+ "templateName": "테스트2",
+ "attachments": [
+ {
+ "filename": "한글.txt",
+ "originalName": "한글.txt",
+ "size": 0,
+ "path": "/app/uploads/mail-attachments/1760317184642-745285906.txt",
+ "mimetype": "text/plain"
+ }
+ ],
+ "status": "success",
+ "messageId": "<1e0abffb-a6cc-8312-d8b4-31c33cb72aa7@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/data/mail-sent/fc26aba3-6b6e-47ba-91e8-609ae25e0e7d.json b/backend-node/data/mail-sent/fc26aba3-6b6e-47ba-91e8-609ae25e0e7d.json
new file mode 100644
index 00000000..f64daf8c
--- /dev/null
+++ b/backend-node/data/mail-sent/fc26aba3-6b6e-47ba-91e8-609ae25e0e7d.json
@@ -0,0 +1,29 @@
+{
+ "id": "fc26aba3-6b6e-47ba-91e8-609ae25e0e7d",
+ "sentAt": "2025-10-13T00:21:51.799Z",
+ "accountId": "account-1759310844272",
+ "accountName": "이희진",
+ "accountEmail": "hjlee@wace.me",
+ "to": [
+ "zian9227@naver.com"
+ ],
+ "subject": "test용입니다.",
+ "htmlContent": "\r\n \r\n ",
+ "templateId": "template-1759302346758",
+ "templateName": "test",
+ "attachments": [
+ {
+ "filename": "웨이스-임직원-프로파일-이희진.key",
+ "originalName": "웨이스-임직원-프로파일-이희진.key",
+ "size": 0,
+ "path": "/app/uploads/mail-attachments/1760314910154-84512253.key",
+ "mimetype": "application/x-iwork-keynote-sffkey"
+ }
+ ],
+ "status": "success",
+ "messageId": "",
+ "accepted": [
+ "zian9227@naver.com"
+ ],
+ "rejected": []
+}
\ No newline at end of file
diff --git a/backend-node/nodemon.json b/backend-node/nodemon.json
new file mode 100644
index 00000000..dc43f881
--- /dev/null
+++ b/backend-node/nodemon.json
@@ -0,0 +1,15 @@
+{
+ "watch": ["src"],
+ "ignore": [
+ "src/**/*.spec.ts",
+ "src/**/*.test.ts",
+ "data/**",
+ "uploads/**",
+ "logs/**",
+ "*.log"
+ ],
+ "ext": "ts,json",
+ "exec": "ts-node src/app.ts",
+ "delay": 2000
+}
+
diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json
index c6a31aa8..7a96aaa2 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",
@@ -30,6 +31,7 @@
"oracledb": "^6.9.0",
"pg": "^8.16.3",
"redis": "^4.6.10",
+ "uuid": "^13.0.0",
"winston": "^3.11.0"
},
"devDependencies": {
@@ -994,6 +996,15 @@
"node": ">=16"
}
},
+ "node_modules/@azure/msal-node/node_modules/uuid": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -4237,6 +4248,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 +6388,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": {
@@ -8030,22 +8057,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",
@@ -8940,6 +8951,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",
@@ -10161,12 +10184,16 @@
}
},
"node_modules/uuid": {
- "version": "8.3.2",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
- "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
+ "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
"license": "MIT",
"bin": {
- "uuid": "dist/bin/uuid"
+ "uuid": "dist-node/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {
diff --git a/backend-node/package.json b/backend-node/package.json
index 910269c1..1f96f8e5 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",
@@ -44,6 +45,7 @@
"oracledb": "^6.9.0",
"pg": "^8.16.3",
"redis": "^4.6.10",
+ "uuid": "^13.0.0",
"winston": "^3.11.0"
},
"devDependencies": {
diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts
index 608abb51..d56d07bb 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";
@@ -48,6 +49,7 @@ import externalCallRoutes from "./routes/externalCallRoutes";
import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
import dashboardRoutes from "./routes/dashboardRoutes";
+import reportRoutes from "./routes/reportRoutes";
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@@ -72,24 +74,33 @@ app.use(
})
);
app.use(compression());
-app.use(express.json({ limit: "10mb" }));
-app.use(express.urlencoded({ extended: true, limit: "10mb" }));
+app.use(express.json({ limit: "50mb" }));
+app.use(express.urlencoded({ extended: true, limit: "50mb" }));
+
+// 정적 파일 서빙 전에 CORS 미들웨어 추가 (OPTIONS 요청 처리)
+app.options("/uploads/*", (req, res) => {
+ res.setHeader("Access-Control-Allow-Origin", "*");
+ res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
+ res.sendStatus(200);
+});
// 정적 파일 서빙 (업로드된 파일들)
app.use(
"/uploads",
- express.static(path.join(process.cwd(), "uploads"), {
- setHeaders: (res, path) => {
- // 파일 서빙 시 CORS 헤더 설정
- res.setHeader("Access-Control-Allow-Origin", "*");
- res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
- res.setHeader(
- "Access-Control-Allow-Headers",
- "Content-Type, Authorization"
- );
- res.setHeader("Cache-Control", "public, max-age=3600");
- },
- })
+ (req, res, next) => {
+ // 모든 정적 파일 요청에 CORS 헤더 추가
+ res.setHeader("Access-Control-Allow-Origin", "*");
+ res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
+ res.setHeader(
+ "Access-Control-Allow-Headers",
+ "Content-Type, Authorization"
+ );
+ res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
+ res.setHeader("Cache-Control", "public, max-age=3600");
+ next();
+ },
+ express.static(path.join(process.cwd(), "uploads"))
);
// CORS 설정 - environment.ts에서 이미 올바른 형태로 처리됨
@@ -164,7 +175,19 @@ app.use("/api/layouts", layoutRoutes);
app.use("/api/mail/accounts", mailAccountFileRoutes); // 파일 기반 계정
app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿
app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송
+// 메일 수신 라우트 디버깅 - 모든 요청 로깅
+app.use("/api/mail/receive", (req, res, next) => {
+ console.log(`\n🔍 [MAIL RECEIVE REQUEST]`);
+ console.log(` Method: ${req.method}`);
+ console.log(` URL: ${req.originalUrl}`);
+ console.log(` Path: ${req.path}`);
+ console.log(` Base URL: ${req.baseUrl}`);
+ console.log(` Params: ${JSON.stringify(req.params)}`);
+ console.log(` Query: ${JSON.stringify(req.query)}`);
+ next();
+});
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);
@@ -181,6 +204,7 @@ app.use("/api/external-calls", externalCallRoutes);
app.use("/api/external-call-configs", externalCallConfigRoutes);
app.use("/api/dataflow", dataflowExecutionRoutes);
app.use("/api/dashboards", dashboardRoutes);
+app.use("/api/admin/reports", reportRoutes);
// app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes);
diff --git a/backend-node/src/config/multerConfig.ts b/backend-node/src/config/multerConfig.ts
new file mode 100644
index 00000000..3179d2b2
--- /dev/null
+++ b/backend-node/src/config/multerConfig.ts
@@ -0,0 +1,118 @@
+import multer from 'multer';
+import path from 'path';
+import fs from 'fs';
+
+// 업로드 디렉토리 경로 (운영: /app/uploads/mail-attachments, 개발: 프로젝트 루트)
+const UPLOAD_DIR = process.env.NODE_ENV === 'production'
+ ? '/app/uploads/mail-attachments'
+ : path.join(process.cwd(), 'uploads', 'mail-attachments');
+
+// 디렉토리 생성 (없으면) - try-catch로 권한 에러 방지
+try {
+ if (!fs.existsSync(UPLOAD_DIR)) {
+ fs.mkdirSync(UPLOAD_DIR, { recursive: true });
+ }
+} catch (error) {
+ console.error('메일 첨부파일 디렉토리 생성 실패:', error);
+ // 디렉토리가 이미 존재하거나 권한이 없어도 서비스는 계속 실행
+}
+
+// 간단한 파일명 정규화 함수 (한글-분석.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/mailReceiveBasicController.ts b/backend-node/src/controllers/mailReceiveBasicController.ts
index ad8b5efa..7722840d 100644
--- a/backend-node/src/controllers/mailReceiveBasicController.ts
+++ b/backend-node/src/controllers/mailReceiveBasicController.ts
@@ -18,6 +18,12 @@ export class MailReceiveBasicController {
*/
async getMailList(req: Request, res: Response) {
try {
+ console.log('📬 메일 목록 조회 요청:', {
+ params: req.params,
+ path: req.path,
+ originalUrl: req.originalUrl
+ });
+
const { accountId } = req.params;
const limit = parseInt(req.query.limit as string) || 50;
@@ -43,6 +49,12 @@ export class MailReceiveBasicController {
*/
async getMailDetail(req: Request, res: Response) {
try {
+ console.log('🔍 메일 상세 조회 요청:', {
+ params: req.params,
+ path: req.path,
+ originalUrl: req.originalUrl
+ });
+
const { accountId, seqno } = req.params;
const seqnoNumber = parseInt(seqno, 10);
@@ -109,29 +121,39 @@ export class MailReceiveBasicController {
*/
async downloadAttachment(req: Request, res: Response) {
try {
+ console.log('📎🎯 컨트롤러 downloadAttachment 진입');
const { accountId, seqno, index } = req.params;
+ console.log(`📎 파라미터: accountId=${accountId}, seqno=${seqno}, index=${index}`);
+
const seqnoNumber = parseInt(seqno, 10);
const indexNumber = parseInt(index, 10);
if (isNaN(seqnoNumber) || isNaN(indexNumber)) {
+ console.log('❌ 유효하지 않은 파라미터');
return res.status(400).json({
success: false,
message: '유효하지 않은 파라미터입니다.',
});
}
+ console.log('📎 서비스 호출 시작...');
const result = await this.mailReceiveService.downloadAttachment(
accountId,
seqnoNumber,
indexNumber
);
+ console.log(`📎 서비스 호출 완료: result=${result ? '있음' : '없음'}`);
if (!result) {
+ console.log('❌ 첨부파일을 찾을 수 없음');
return res.status(404).json({
success: false,
message: '첨부파일을 찾을 수 없습니다.',
});
}
+
+ console.log(`📎 파일 다운로드 시작: ${result.filename}`);
+ console.log(`📎 파일 경로: ${result.filePath}`);
// 파일 다운로드
res.download(result.filePath, result.filename, (err) => {
@@ -173,5 +195,27 @@ export class MailReceiveBasicController {
});
}
}
+
+ /**
+ * GET /api/mail/receive/today-count
+ * 오늘 수신 메일 수 조회
+ */
+ async getTodayReceivedCount(req: Request, res: Response) {
+ try {
+ const { accountId } = req.query;
+ const count = await this.mailReceiveService.getTodayReceivedCount(accountId as string);
+
+ return res.json({
+ success: true,
+ data: { count }
+ });
+ } catch (error: unknown) {
+ console.error('오늘 수신 메일 수 조회 실패:', error);
+ return res.status(500).json({
+ success: false,
+ message: error instanceof Error ? error.message : '오늘 수신 메일 수 조회에 실패했습니다.'
+ });
+ }
+ }
}
diff --git a/backend-node/src/controllers/mailSendSimpleController.ts b/backend-node/src/controllers/mailSendSimpleController.ts
index 15a1bea0..de8610b7 100644
--- a/backend-node/src/controllers/mailSendSimpleController.ts
+++ b/backend-node/src/controllers/mailSendSimpleController.ts
@@ -3,12 +3,31 @@ 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 modifiedTemplateComponents = req.body.modifiedTemplateComponents
+ ? JSON.parse(req.body.modifiedTemplateComponents)
+ : undefined; // 🎯 수정된 템플릿 컴포넌트
+ 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 +53,54 @@ 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,
+ modifiedTemplateComponents, // 🎯 수정된 템플릿 컴포넌트 전달
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/controllers/reportController.ts b/backend-node/src/controllers/reportController.ts
new file mode 100644
index 00000000..f9162016
--- /dev/null
+++ b/backend-node/src/controllers/reportController.ts
@@ -0,0 +1,539 @@
+/**
+ * 리포트 관리 컨트롤러
+ */
+
+import { Request, Response, NextFunction } from "express";
+import reportService from "../services/reportService";
+import {
+ CreateReportRequest,
+ UpdateReportRequest,
+ SaveLayoutRequest,
+ CreateTemplateRequest,
+} from "../types/report";
+import path from "path";
+import fs from "fs";
+
+export class ReportController {
+ /**
+ * 리포트 목록 조회
+ * GET /api/admin/reports
+ */
+ async getReports(req: Request, res: Response, next: NextFunction) {
+ try {
+ const {
+ page = "1",
+ limit = "20",
+ searchText = "",
+ reportType = "",
+ useYn = "Y",
+ sortBy = "created_at",
+ sortOrder = "DESC",
+ } = req.query;
+
+ const result = await reportService.getReports({
+ page: parseInt(page as string, 10),
+ limit: parseInt(limit as string, 10),
+ searchText: searchText as string,
+ reportType: reportType as string,
+ useYn: useYn as string,
+ sortBy: sortBy as string,
+ sortOrder: sortOrder as "ASC" | "DESC",
+ });
+
+ return res.json({
+ success: true,
+ data: result,
+ });
+ } catch (error) {
+ return next(error);
+ }
+ }
+
+ /**
+ * 리포트 상세 조회
+ * GET /api/admin/reports/:reportId
+ */
+ async getReportById(req: Request, res: Response, next: NextFunction) {
+ try {
+ const { reportId } = req.params;
+
+ const report = await reportService.getReportById(reportId);
+
+ if (!report) {
+ return res.status(404).json({
+ success: false,
+ message: "리포트를 찾을 수 없습니다.",
+ });
+ }
+
+ return res.json({
+ success: true,
+ data: report,
+ });
+ } catch (error) {
+ return next(error);
+ }
+ }
+
+ /**
+ * 리포트 생성
+ * POST /api/admin/reports
+ */
+ async createReport(req: Request, res: Response, next: NextFunction) {
+ try {
+ const data: CreateReportRequest = req.body;
+ const userId = (req as any).user?.userId || "SYSTEM";
+
+ // 필수 필드 검증
+ if (!data.reportNameKor || !data.reportType) {
+ return res.status(400).json({
+ success: false,
+ message: "리포트명과 리포트 타입은 필수입니다.",
+ });
+ }
+
+ const reportId = await reportService.createReport(data, userId);
+
+ return res.status(201).json({
+ success: true,
+ data: {
+ reportId,
+ },
+ message: "리포트가 생성되었습니다.",
+ });
+ } catch (error) {
+ return next(error);
+ }
+ }
+
+ /**
+ * 리포트 수정
+ * PUT /api/admin/reports/:reportId
+ */
+ async updateReport(req: Request, res: Response, next: NextFunction) {
+ try {
+ const { reportId } = req.params;
+ const data: UpdateReportRequest = req.body;
+ const userId = (req as any).user?.userId || "SYSTEM";
+
+ const success = await reportService.updateReport(reportId, data, userId);
+
+ if (!success) {
+ return res.status(400).json({
+ success: false,
+ message: "수정할 내용이 없습니다.",
+ });
+ }
+
+ return res.json({
+ success: true,
+ message: "리포트가 수정되었습니다.",
+ });
+ } catch (error) {
+ return next(error);
+ }
+ }
+
+ /**
+ * 리포트 삭제
+ * DELETE /api/admin/reports/:reportId
+ */
+ async deleteReport(req: Request, res: Response, next: NextFunction) {
+ try {
+ const { reportId } = req.params;
+
+ const success = await reportService.deleteReport(reportId);
+
+ if (!success) {
+ return res.status(404).json({
+ success: false,
+ message: "리포트를 찾을 수 없습니다.",
+ });
+ }
+
+ return res.json({
+ success: true,
+ message: "리포트가 삭제되었습니다.",
+ });
+ } catch (error) {
+ return next(error);
+ }
+ }
+
+ /**
+ * 리포트 복사
+ * POST /api/admin/reports/:reportId/copy
+ */
+ async copyReport(req: Request, res: Response, next: NextFunction) {
+ try {
+ const { reportId } = req.params;
+ const userId = (req as any).user?.userId || "SYSTEM";
+
+ const newReportId = await reportService.copyReport(reportId, userId);
+
+ if (!newReportId) {
+ return res.status(404).json({
+ success: false,
+ message: "리포트를 찾을 수 없습니다.",
+ });
+ }
+
+ return res.status(201).json({
+ success: true,
+ data: {
+ reportId: newReportId,
+ },
+ message: "리포트가 복사되었습니다.",
+ });
+ } catch (error) {
+ return next(error);
+ }
+ }
+
+ /**
+ * 레이아웃 조회
+ * GET /api/admin/reports/:reportId/layout
+ */
+ async getLayout(req: Request, res: Response, next: NextFunction) {
+ try {
+ const { reportId } = req.params;
+
+ const layout = await reportService.getLayout(reportId);
+
+ if (!layout) {
+ return res.status(404).json({
+ success: false,
+ message: "레이아웃을 찾을 수 없습니다.",
+ });
+ }
+
+ // components JSON 파싱
+ const layoutData = {
+ ...layout,
+ components: layout.components ? JSON.parse(layout.components) : [],
+ };
+
+ return res.json({
+ success: true,
+ data: layoutData,
+ });
+ } catch (error) {
+ return next(error);
+ }
+ }
+
+ /**
+ * 레이아웃 저장
+ * PUT /api/admin/reports/:reportId/layout
+ */
+ async saveLayout(req: Request, res: Response, next: NextFunction) {
+ try {
+ const { reportId } = req.params;
+ const data: SaveLayoutRequest = req.body;
+ const userId = (req as any).user?.userId || "SYSTEM";
+
+ // 필수 필드 검증
+ if (
+ !data.canvasWidth ||
+ !data.canvasHeight ||
+ !data.pageOrientation ||
+ !data.components
+ ) {
+ return res.status(400).json({
+ success: false,
+ message: "필수 레이아웃 정보가 누락되었습니다.",
+ });
+ }
+
+ await reportService.saveLayout(reportId, data, userId);
+
+ return res.json({
+ success: true,
+ message: "레이아웃이 저장되었습니다.",
+ });
+ } catch (error) {
+ return next(error);
+ }
+ }
+
+ /**
+ * 템플릿 목록 조회
+ * GET /api/admin/reports/templates
+ */
+ async getTemplates(req: Request, res: Response, next: NextFunction) {
+ try {
+ const templates = await reportService.getTemplates();
+
+ return res.json({
+ success: true,
+ data: templates,
+ });
+ } catch (error) {
+ return next(error);
+ }
+ }
+
+ /**
+ * 템플릿 생성
+ * POST /api/admin/reports/templates
+ */
+ async createTemplate(req: Request, res: Response, next: NextFunction) {
+ try {
+ const data: CreateTemplateRequest = req.body;
+ const userId = (req as any).user?.userId || "SYSTEM";
+
+ // 필수 필드 검증
+ if (!data.templateNameKor || !data.templateType) {
+ return res.status(400).json({
+ success: false,
+ message: "템플릿명과 템플릿 타입은 필수입니다.",
+ });
+ }
+
+ const templateId = await reportService.createTemplate(data, userId);
+
+ return res.status(201).json({
+ success: true,
+ data: {
+ templateId,
+ },
+ message: "템플릿이 생성되었습니다.",
+ });
+ } catch (error) {
+ return next(error);
+ }
+ }
+
+ /**
+ * 현재 리포트를 템플릿으로 저장
+ * POST /api/admin/reports/:reportId/save-as-template
+ */
+ async saveAsTemplate(req: Request, res: Response, next: NextFunction) {
+ try {
+ const { reportId } = req.params;
+ const { templateNameKor, templateNameEng, description } = req.body;
+ const userId = (req as any).user?.userId || "SYSTEM";
+
+ // 필수 필드 검증
+ if (!templateNameKor) {
+ return res.status(400).json({
+ success: false,
+ message: "템플릿명은 필수입니다.",
+ });
+ }
+
+ const templateId = await reportService.saveAsTemplate(
+ reportId,
+ templateNameKor,
+ templateNameEng,
+ description,
+ userId
+ );
+
+ return res.status(201).json({
+ success: true,
+ data: {
+ templateId,
+ },
+ message: "템플릿이 저장되었습니다.",
+ });
+ } catch (error) {
+ return next(error);
+ }
+ }
+
+ /**
+ * 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
+ * POST /api/admin/reports/templates/create-from-layout
+ */
+ async createTemplateFromLayout(
+ req: Request,
+ res: Response,
+ next: NextFunction
+ ) {
+ try {
+ const {
+ templateNameKor,
+ templateNameEng,
+ templateType,
+ description,
+ layoutConfig,
+ defaultQueries = [],
+ } = req.body;
+ const userId = (req as any).user?.userId || "SYSTEM";
+
+ // 필수 필드 검증
+ if (!templateNameKor) {
+ return res.status(400).json({
+ success: false,
+ message: "템플릿명은 필수입니다.",
+ });
+ }
+
+ if (!layoutConfig) {
+ return res.status(400).json({
+ success: false,
+ message: "레이아웃 설정은 필수입니다.",
+ });
+ }
+
+ const templateId = await reportService.createTemplateFromLayout(
+ templateNameKor,
+ templateNameEng,
+ templateType || "GENERAL",
+ description,
+ layoutConfig,
+ defaultQueries,
+ userId
+ );
+
+ return res.status(201).json({
+ success: true,
+ data: {
+ templateId,
+ },
+ message: "템플릿이 생성되었습니다.",
+ });
+ } catch (error) {
+ return next(error);
+ }
+ }
+
+ /**
+ * 템플릿 삭제
+ * DELETE /api/admin/reports/templates/:templateId
+ */
+ async deleteTemplate(req: Request, res: Response, next: NextFunction) {
+ try {
+ const { templateId } = req.params;
+
+ const success = await reportService.deleteTemplate(templateId);
+
+ if (!success) {
+ return res.status(404).json({
+ success: false,
+ message: "템플릿을 찾을 수 없거나 시스템 템플릿입니다.",
+ });
+ }
+
+ return res.json({
+ success: true,
+ message: "템플릿이 삭제되었습니다.",
+ });
+ } catch (error) {
+ return next(error);
+ }
+ }
+
+ /**
+ * 쿼리 실행
+ * POST /api/admin/reports/:reportId/queries/:queryId/execute
+ */
+ async executeQuery(req: Request, res: Response, next: NextFunction) {
+ try {
+ const { reportId, queryId } = req.params;
+ const { parameters = {}, sqlQuery, externalConnectionId } = req.body;
+
+ const result = await reportService.executeQuery(
+ reportId,
+ queryId,
+ parameters,
+ sqlQuery,
+ externalConnectionId
+ );
+
+ return res.json({
+ success: true,
+ data: result,
+ });
+ } catch (error: any) {
+ return res.status(400).json({
+ success: false,
+ message: error.message || "쿼리 실행에 실패했습니다.",
+ });
+ }
+ }
+
+ /**
+ * 외부 DB 연결 목록 조회 (활성화된 것만)
+ * GET /api/admin/reports/external-connections
+ */
+ async getExternalConnections(
+ req: Request,
+ res: Response,
+ next: NextFunction
+ ) {
+ try {
+ const { ExternalDbConnectionService } = await import(
+ "../services/externalDbConnectionService"
+ );
+
+ const result = await ExternalDbConnectionService.getConnections({
+ is_active: "Y",
+ company_code: req.body.companyCode || "",
+ });
+
+ return res.json(result);
+ } catch (error) {
+ return next(error);
+ }
+ }
+
+ /**
+ * 이미지 파일 업로드
+ * POST /api/admin/reports/upload-image
+ */
+ async uploadImage(req: Request, res: Response, next: NextFunction) {
+ try {
+ if (!req.file) {
+ return res.status(400).json({
+ success: false,
+ message: "이미지 파일이 필요합니다.",
+ });
+ }
+
+ const companyCode = req.body.companyCode || "SYSTEM";
+ const file = req.file;
+
+ // 파일 저장 경로 생성
+ const uploadDir = path.join(
+ process.cwd(),
+ "uploads",
+ `company_${companyCode}`,
+ "reports"
+ );
+
+ // 디렉토리가 없으면 생성
+ if (!fs.existsSync(uploadDir)) {
+ fs.mkdirSync(uploadDir, { recursive: true });
+ }
+
+ // 고유한 파일명 생성 (타임스탬프 + 원본 파일명)
+ const timestamp = Date.now();
+ const safeFileName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, "_");
+ const fileName = `${timestamp}_${safeFileName}`;
+ const filePath = path.join(uploadDir, fileName);
+
+ // 파일 저장
+ fs.writeFileSync(filePath, file.buffer);
+
+ // 웹에서 접근 가능한 URL 반환
+ const fileUrl = `/uploads/company_${companyCode}/reports/${fileName}`;
+
+ return res.json({
+ success: true,
+ data: {
+ fileName,
+ fileUrl,
+ originalName: file.originalname,
+ size: file.size,
+ mimeType: file.mimetype,
+ },
+ });
+ } catch (error) {
+ return next(error);
+ }
+ }
+}
+
+export default new ReportController();
diff --git a/backend-node/src/routes/mailReceiveBasicRoutes.ts b/backend-node/src/routes/mailReceiveBasicRoutes.ts
index d21df689..d40c4629 100644
--- a/backend-node/src/routes/mailReceiveBasicRoutes.ts
+++ b/backend-node/src/routes/mailReceiveBasicRoutes.ts
@@ -12,20 +12,29 @@ const router = express.Router();
router.use(authenticateToken);
const controller = new MailReceiveBasicController();
-// 메일 목록 조회
-router.get('/:accountId', (req, res) => controller.getMailList(req, res));
+// 오늘 수신 메일 수 조회 (통계) - 가장 먼저 정의 (가장 구체적)
+router.get('/today-count', (req, res) => controller.getTodayReceivedCount(req, res));
-// 메일 상세 조회
-router.get('/:accountId/:seqno', (req, res) => controller.getMailDetail(req, res));
+// 첨부파일 다운로드 - 매우 구체적인 경로
+router.get('/:accountId/:seqno/attachment/:index', (req, res) => {
+ console.log(`📎 첨부파일 라우트 핸들러 진입!`);
+ console.log(` accountId: ${req.params.accountId}`);
+ console.log(` seqno: ${req.params.seqno}`);
+ console.log(` index: ${req.params.index}`);
+ controller.downloadAttachment(req, res);
+});
-// 첨부파일 다운로드 (상세 조회보다 먼저 정의해야 함)
-router.get('/:accountId/:seqno/attachment/:index', (req, res) => controller.downloadAttachment(req, res));
-
-// 메일 읽음 표시
+// 메일 읽음 표시 - 구체적인 경로
router.post('/:accountId/:seqno/mark-read', (req, res) => controller.markAsRead(req, res));
-// IMAP 연결 테스트
+// 메일 상세 조회 - /:accountId보다 먼저 정의해야 함
+router.get('/:accountId/:seqno', (req, res) => controller.getMailDetail(req, res));
+
+// IMAP 연결 테스트 - /:accountId보다 먼저 정의해야 함
router.post('/:accountId/test-imap', (req, res) => controller.testImapConnection(req, res));
+// 메일 목록 조회 - 가장 마지막에 정의 (가장 일반적)
+router.get('/:accountId', (req, res) => controller.getMailList(req, res));
+
export default router;
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/routes/reportRoutes.ts b/backend-node/src/routes/reportRoutes.ts
new file mode 100644
index 00000000..76e1a955
--- /dev/null
+++ b/backend-node/src/routes/reportRoutes.ts
@@ -0,0 +1,107 @@
+import { Router } from "express";
+import reportController from "../controllers/reportController";
+import { authenticateToken } from "../middleware/authMiddleware";
+import multer from "multer";
+
+const router = Router();
+
+// Multer 설정 (메모리 저장)
+const upload = multer({
+ storage: multer.memoryStorage(),
+ limits: {
+ fileSize: 10 * 1024 * 1024, // 10MB 제한
+ },
+ fileFilter: (req, file, cb) => {
+ // 이미지 파일만 허용
+ const allowedTypes = [
+ "image/jpeg",
+ "image/jpg",
+ "image/png",
+ "image/gif",
+ "image/webp",
+ ];
+ if (allowedTypes.includes(file.mimetype)) {
+ cb(null, true);
+ } else {
+ cb(new Error("이미지 파일만 업로드 가능합니다. (jpg, png, gif, webp)"));
+ }
+ },
+});
+
+// 모든 리포트 API는 인증이 필요
+router.use(authenticateToken);
+
+// 외부 DB 연결 목록 (구체적인 경로를 먼저 배치)
+router.get("/external-connections", (req, res, next) =>
+ reportController.getExternalConnections(req, res, next)
+);
+
+// 템플릿 관련 라우트
+router.get("/templates", (req, res, next) =>
+ reportController.getTemplates(req, res, next)
+);
+router.post("/templates", (req, res, next) =>
+ reportController.createTemplate(req, res, next)
+);
+// 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
+router.post("/templates/create-from-layout", (req, res, next) =>
+ reportController.createTemplateFromLayout(req, res, next)
+);
+router.delete("/templates/:templateId", (req, res, next) =>
+ reportController.deleteTemplate(req, res, next)
+);
+
+// 이미지 업로드 (구체적인 경로를 먼저 배치)
+router.post("/upload-image", upload.single("image"), (req, res, next) =>
+ reportController.uploadImage(req, res, next)
+);
+
+// 리포트 목록
+router.get("/", (req, res, next) =>
+ reportController.getReports(req, res, next)
+);
+
+// 리포트 생성
+router.post("/", (req, res, next) =>
+ reportController.createReport(req, res, next)
+);
+
+// 리포트 복사 (구체적인 경로를 먼저 배치)
+router.post("/:reportId/copy", (req, res, next) =>
+ reportController.copyReport(req, res, next)
+);
+
+// 템플릿으로 저장
+router.post("/:reportId/save-as-template", (req, res, next) =>
+ reportController.saveAsTemplate(req, res, next)
+);
+
+// 레이아웃 관련 라우트
+router.get("/:reportId/layout", (req, res, next) =>
+ reportController.getLayout(req, res, next)
+);
+router.put("/:reportId/layout", (req, res, next) =>
+ reportController.saveLayout(req, res, next)
+);
+
+// 쿼리 실행
+router.post("/:reportId/queries/:queryId/execute", (req, res, next) =>
+ reportController.executeQuery(req, res, next)
+);
+
+// 리포트 상세
+router.get("/:reportId", (req, res, next) =>
+ reportController.getReportById(req, res, next)
+);
+
+// 리포트 수정
+router.put("/:reportId", (req, res, next) =>
+ reportController.updateReport(req, res, next)
+);
+
+// 리포트 삭제
+router.delete("/:reportId", (req, res, next) =>
+ reportController.deleteReport(req, res, next)
+);
+
+export default router;
diff --git a/backend-node/src/services/mailAccountFileService.ts b/backend-node/src/services/mailAccountFileService.ts
index d81d2c37..7b07b531 100644
--- a/backend-node/src/services/mailAccountFileService.ts
+++ b/backend-node/src/services/mailAccountFileService.ts
@@ -1,6 +1,6 @@
-import fs from 'fs/promises';
-import path from 'path';
-import { encryptionService } from './encryptionService';
+import fs from "fs/promises";
+import path from "path";
+import { encryptionService } from "./encryptionService";
export interface MailAccount {
id: string;
@@ -12,7 +12,7 @@ export interface MailAccount {
smtpUsername: string;
smtpPassword: string; // 암호화된 비밀번호
dailyLimit: number;
- status: 'active' | 'inactive' | 'suspended';
+ status: "active" | "inactive" | "suspended";
createdAt: string;
updatedAt: string;
}
@@ -21,7 +21,11 @@ class MailAccountFileService {
private accountsDir: string;
constructor() {
- this.accountsDir = path.join(process.cwd(), 'uploads', 'mail-accounts');
+ // 운영 환경에서는 /app/uploads/mail-accounts, 개발 환경에서는 프로젝트 루트
+ this.accountsDir =
+ process.env.NODE_ENV === "production"
+ ? "/app/uploads/mail-accounts"
+ : path.join(process.cwd(), "uploads", "mail-accounts");
this.ensureDirectoryExists();
}
@@ -29,7 +33,11 @@ class MailAccountFileService {
try {
await fs.access(this.accountsDir);
} catch {
- await fs.mkdir(this.accountsDir, { recursive: true });
+ try {
+ await fs.mkdir(this.accountsDir, { recursive: true });
+ } catch (error) {
+ console.error("메일 계정 디렉토리 생성 실패:", error);
+ }
}
}
@@ -39,23 +47,24 @@ class MailAccountFileService {
async getAllAccounts(): Promise {
await this.ensureDirectoryExists();
-
+
try {
const files = await fs.readdir(this.accountsDir);
- const jsonFiles = files.filter(f => f.endsWith('.json'));
+ const jsonFiles = files.filter((f) => f.endsWith(".json"));
const accounts = await Promise.all(
jsonFiles.map(async (file) => {
const content = await fs.readFile(
path.join(this.accountsDir, file),
- 'utf-8'
+ "utf-8"
);
return JSON.parse(content) as MailAccount;
})
);
- return accounts.sort((a, b) =>
- new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
+ return accounts.sort(
+ (a, b) =>
+ new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
} catch {
return [];
@@ -64,7 +73,7 @@ class MailAccountFileService {
async getAccountById(id: string): Promise {
try {
- const content = await fs.readFile(this.getAccountPath(id), 'utf-8');
+ const content = await fs.readFile(this.getAccountPath(id), "utf-8");
return JSON.parse(content);
} catch {
return null;
@@ -72,7 +81,7 @@ class MailAccountFileService {
}
async createAccount(
- data: Omit
+ data: Omit
): Promise {
const id = `account-${Date.now()}`;
const now = new Date().toISOString();
@@ -91,7 +100,7 @@ class MailAccountFileService {
await fs.writeFile(
this.getAccountPath(id),
JSON.stringify(account, null, 2),
- 'utf-8'
+ "utf-8"
);
return account;
@@ -99,7 +108,7 @@ class MailAccountFileService {
async updateAccount(
id: string,
- data: Partial>
+ data: Partial>
): Promise {
const existing = await this.getAccountById(id);
if (!existing) {
@@ -122,7 +131,7 @@ class MailAccountFileService {
await fs.writeFile(
this.getAccountPath(id),
JSON.stringify(updated, null, 2),
- 'utf-8'
+ "utf-8"
);
return updated;
@@ -139,12 +148,12 @@ class MailAccountFileService {
async getAccountByEmail(email: string): Promise {
const accounts = await this.getAllAccounts();
- return accounts.find(a => a.email === email) || null;
+ return accounts.find((a) => a.email === email) || null;
}
async getActiveAccounts(): Promise {
const accounts = await this.getAllAccounts();
- return accounts.filter(a => a.status === 'active');
+ return accounts.filter((a) => a.status === "active");
}
/**
@@ -156,4 +165,3 @@ class MailAccountFileService {
}
export const mailAccountFileService = new MailAccountFileService();
-
diff --git a/backend-node/src/services/mailReceiveBasicService.ts b/backend-node/src/services/mailReceiveBasicService.ts
index 9208a852..741353fa 100644
--- a/backend-node/src/services/mailReceiveBasicService.ts
+++ b/backend-node/src/services/mailReceiveBasicService.ts
@@ -3,11 +3,13 @@
* IMAP 연결 및 메일 목록 조회
*/
-import * as Imap from 'imap';
-import { simpleParser } from 'mailparser';
-import { mailAccountFileService } from './mailAccountFileService';
-import fs from 'fs/promises';
-import path from 'path';
+// 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";
export interface ReceivedMail {
id: string;
@@ -45,7 +47,11 @@ export class MailReceiveBasicService {
private attachmentsDir: string;
constructor() {
- this.attachmentsDir = path.join(process.cwd(), 'uploads', 'mail-attachments');
+ // 운영 환경에서는 /app/uploads/mail-attachments, 개발 환경에서는 프로젝트 루트
+ this.attachmentsDir =
+ process.env.NODE_ENV === "production"
+ ? "/app/uploads/mail-attachments"
+ : path.join(process.cwd(), "uploads", "mail-attachments");
this.ensureDirectoryExists();
}
@@ -53,10 +59,28 @@ export class MailReceiveBasicService {
try {
await fs.access(this.attachmentsDir);
} catch {
- await fs.mkdir(this.attachmentsDir, { recursive: true });
+ try {
+ await fs.mkdir(this.attachmentsDir, { recursive: true });
+ } catch (error) {
+ console.error("메일 첨부파일 디렉토리 생성 실패:", error);
+ }
}
}
+ /**
+ * 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 연결 생성
*/
@@ -74,33 +98,56 @@ export class MailReceiveBasicService {
/**
* 메일 계정으로 받은 메일 목록 조회
*/
- async fetchMailList(accountId: string, limit: number = 50): Promise {
+ async fetchMailList(
+ accountId: string,
+ limit: number = 50
+ ): Promise {
const account = await mailAccountFileService.getAccountById(accountId);
if (!account) {
- throw new Error('메일 계정을 찾을 수 없습니다.');
+ 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[] = [];
- imap.once('ready', () => {
- imap.openBox('INBOX', true, (err: any, box: any) => {
+ // 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,78 +156,133 @@ 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'],
+ bodies: ["HEADER", "TEXT"],
struct: true,
});
- fetch.on('message', (msg: any, seqno: any) => {
- let header: string = '';
- let body: string = '';
- let attributes: any = null;
+ // console.log(`📦 fetch 객체 생성 완료`);
- msg.on('body', (stream: any, info: any) => {
- let buffer = '';
- stream.on('data', (chunk: any) => {
- buffer += chunk.toString('utf8');
+ 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 = "";
+ stream.on("data", (chunk: any) => {
+ buffer += chunk.toString("utf8");
});
- stream.once('end', () => {
- if (info.which === 'HEADER') {
+ stream.once("end", () => {
+ if (info.which === "HEADER") {
header = buffer;
} else {
body = buffer;
}
+ bodiesReceived++;
});
});
- msg.once('attributes', (attrs: any) => {
+ msg.once("attributes", (attrs: any) => {
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);
- const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from;
- const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to;
+ try {
+ const parsed = await simpleParser(
+ header + "\r\n\r\n" + body
+ );
- 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 fromAddress = Array.isArray(parsed.from)
+ ? parsed.from[0]
+ : parsed.from;
+ const toAddress = Array.isArray(parsed.to)
+ ? parsed.to[0]
+ : parsed.to;
- mails.push(mail);
- } catch (parseError) {
- console.error('메일 파싱 오류:', parseError);
- }
+ 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);
+ // console.log(`✓ 메일 #${seqno} 파싱 완료 (${mails.length}/${totalToProcess})`);
+ processedCount++;
+ } catch (parseError) {
+ // console.error(`메일 #${seqno} 파싱 오류:`, parseError);
+ processedCount++;
+ }
+ }
+ }, 50);
});
});
- fetch.once('error', (fetchErr: any) => {
+ fetch.once("error", (fetchErr: any) => {
+ // console.error('❌ 메일 fetch 에러:', fetchErr);
imap.end();
reject(fetchErr);
});
- fetch.once('end', () => {
- imap.end();
- // 최신 메일이 위로 오도록 정렬
- mails.sort((a, b) => b.date.getTime() - a.date.getTime());
- resolve(mails);
+ fetch.once("end", () => {
+ // 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) => {
+ 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();
});
}
@@ -190,102 +292,169 @@ export class MailReceiveBasicService {
*/
private extractPreview(text: string): string {
// HTML 태그 제거
- const plainText = text.replace(/<[^>]*>/g, '');
+ const plainText = text.replace(/<[^>]*>/g, "");
// 공백 정리
- const cleaned = plainText.replace(/\s+/g, ' ').trim();
+ const cleaned = plainText.replace(/\s+/g, " ").trim();
// 최대 150자
- return cleaned.length > 150 ? cleaned.substring(0, 150) + '...' : cleaned;
+ return cleaned.length > 150 ? cleaned.substring(0, 150) + "..." : cleaned;
}
/**
* 메일 상세 조회
*/
- async getMailDetail(accountId: string, seqno: number): Promise {
+ async getMailDetail(
+ accountId: string,
+ seqno: number
+ ): Promise {
const account = await mailAccountFileService.getAccountById(accountId);
if (!account) {
- throw new Error('메일 계정을 찾을 수 없습니다.');
+ 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,
};
return new Promise((resolve, reject) => {
const imap = this.createImapConnection(imapConfig);
- imap.once('ready', () => {
- imap.openBox('INBOX', false, (err: any, box: any) => {
+ imap.once("ready", () => {
+ imap.openBox("INBOX", false, (err: any, box: any) => {
if (err) {
imap.end();
return reject(err);
}
+ console.log(
+ `📬 INBOX 정보 - 전체 메일: ${box.messages.total}, 요청한 seqno: ${seqno}`
+ );
+
+ if (seqno > box.messages.total || seqno < 1) {
+ console.error(
+ `❌ 유효하지 않은 seqno: ${seqno} (메일 총 개수: ${box.messages.total})`
+ );
+ imap.end();
+ return resolve(null);
+ }
+
const fetch = imap.seq.fetch(`${seqno}:${seqno}`, {
- bodies: '',
+ bodies: "",
struct: true,
});
let mailDetail: MailDetail | null = null;
+ let parsingComplete = false;
- fetch.on('message', (msg: any, seqnum: any) => {
- msg.on('body', (stream: any, info: any) => {
- let buffer = '';
- stream.on('data', (chunk: any) => {
- buffer += chunk.toString('utf8');
+ fetch.on("message", (msg: any, seqnum: any) => {
+ console.log(`📨 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
+
+ msg.on("body", (stream: any, info: any) => {
+ console.log(`📝 메일 본문 스트림 시작 - which: ${info.which}`);
+ let buffer = "";
+ stream.on("data", (chunk: any) => {
+ buffer += chunk.toString("utf8");
});
- stream.once('end', async () => {
+ stream.once("end", async () => {
+ console.log(
+ `✅ 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`
+ );
try {
const parsed = await simpleParser(buffer);
+ console.log(`✅ 메일 파싱 완료 - 제목: ${parsed.subject}`);
- const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from;
- const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to;
- const ccAddress = Array.isArray(parsed.cc) ? parsed.cc[0] : parsed.cc;
- const bccAddress = Array.isArray(parsed.bcc) ? parsed.bcc[0] : parsed.bcc;
+ const fromAddress = Array.isArray(parsed.from)
+ ? parsed.from[0]
+ : parsed.from;
+ const toAddress = Array.isArray(parsed.to)
+ ? parsed.to[0]
+ : parsed.to;
+ const ccAddress = Array.isArray(parsed.cc)
+ ? parsed.cc[0]
+ : parsed.cc;
+ const bccAddress = Array.isArray(parsed.bcc)
+ ? parsed.bcc[0]
+ : parsed.bcc;
mailDetail = {
id: `${accountId}-${seqnum}`,
messageId: parsed.messageId || `${seqnum}`,
- from: fromAddress?.text || 'Unknown',
- to: toAddress?.text || '',
+ from: fromAddress?.text || "Unknown",
+ to: toAddress?.text || "",
cc: ccAddress?.text,
bcc: bccAddress?.text,
- subject: parsed.subject || '(제목 없음)',
+ subject: parsed.subject || "(제목 없음)",
date: parsed.date || new Date(),
- htmlBody: parsed.html || '',
- textBody: parsed.text || '',
- preview: this.extractPreview(parsed.text || parsed.html || ''),
+ htmlBody: parsed.html || "",
+ textBody: parsed.text || "",
+ preview: this.extractPreview(
+ parsed.text || parsed.html || ""
+ ),
isRead: true, // 조회 시 읽음으로 표시
hasAttachments: (parsed.attachments?.length || 0) > 0,
attachments: (parsed.attachments || []).map((att: any) => ({
- filename: att.filename || 'unnamed',
- contentType: att.contentType || 'application/octet-stream',
+ filename: att.filename || "unnamed",
+ contentType:
+ att.contentType || "application/octet-stream",
size: att.size || 0,
})),
};
+ parsingComplete = true;
} catch (parseError) {
- console.error('메일 파싱 오류:', parseError);
+ console.error("메일 파싱 오류:", parseError);
+ parsingComplete = true;
}
});
});
+
+ // msg 전체가 처리되었을 때 이벤트
+ msg.once("end", () => {
+ console.log(`📮 메일 메시지 처리 완료 - seqnum: ${seqnum}`);
+ });
});
- fetch.once('error', (fetchErr: any) => {
+ fetch.once("error", (fetchErr: any) => {
+ console.error(`❌ Fetch 에러:`, fetchErr);
imap.end();
reject(fetchErr);
});
- fetch.once('end', () => {
- imap.end();
- resolve(mailDetail);
+ fetch.once("end", () => {
+ console.log(`🏁 Fetch 종료 - parsingComplete: ${parsingComplete}`);
+
+ // 비동기 파싱이 완료될 때까지 대기
+ const waitForParsing = setInterval(() => {
+ if (parsingComplete) {
+ clearInterval(waitForParsing);
+ console.log(
+ `✅ 파싱 완료 대기 종료 - mailDetail이 ${mailDetail ? "존재함" : "null"}`
+ );
+ imap.end();
+ resolve(mailDetail);
+ }
+ }, 10); // 10ms마다 체크
+
+ // 타임아웃 설정 (10초)
+ setTimeout(() => {
+ if (!parsingComplete) {
+ clearInterval(waitForParsing);
+ console.error("❌ 파싱 타임아웃");
+ imap.end();
+ resolve(mailDetail); // 타임아웃 시에도 현재 상태 반환
+ }
+ }, 10000);
});
});
});
- imap.once('error', (imapErr: any) => {
+ imap.once("error", (imapErr: any) => {
reject(imapErr);
});
@@ -296,45 +465,52 @@ export class MailReceiveBasicService {
/**
* 메일을 읽음으로 표시
*/
- async markAsRead(accountId: string, seqno: number): Promise<{ success: boolean; message: string }> {
+ async markAsRead(
+ accountId: string,
+ seqno: number
+ ): Promise<{ success: boolean; message: string }> {
const account = await mailAccountFileService.getAccountById(accountId);
if (!account) {
- throw new Error('메일 계정을 찾을 수 없습니다.');
+ 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,
};
return new Promise((resolve, reject) => {
const imap = this.createImapConnection(imapConfig);
- imap.once('ready', () => {
- imap.openBox('INBOX', false, (err: any, box: any) => {
+ imap.once("ready", () => {
+ imap.openBox("INBOX", false, (err: any, box: any) => {
if (err) {
imap.end();
return reject(err);
}
- imap.seq.addFlags(seqno, ['\\Seen'], (flagErr: any) => {
+ imap.seq.addFlags(seqno, ["\\Seen"], (flagErr: any) => {
imap.end();
if (flagErr) {
reject(flagErr);
} else {
resolve({
success: true,
- message: '메일을 읽음으로 표시했습니다.',
+ message: "메일을 읽음으로 표시했습니다.",
});
}
});
});
});
- imap.once('error', (imapErr: any) => {
+ imap.once("error", (imapErr: any) => {
reject(imapErr);
});
@@ -345,43 +521,51 @@ export class MailReceiveBasicService {
/**
* IMAP 연결 테스트
*/
- async testImapConnection(accountId: string): Promise<{ success: boolean; message: string }> {
+ async testImapConnection(
+ accountId: string
+ ): Promise<{ success: boolean; message: string }> {
try {
const account = await mailAccountFileService.getAccountById(accountId);
if (!account) {
- throw new Error('메일 계정을 찾을 수 없습니다.');
+ throw new Error("메일 계정을 찾을 수 없습니다.");
}
+ // 비밀번호 복호화
+ const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
+ // console.log(`🔐 IMAP 테스트 - 이메일: ${account.email}, 비밀번호 길이: ${decryptedPassword.length}`);
+
+ const accountAny = account as any;
const imapConfig: ImapConfig = {
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);
- imap.once('ready', () => {
+ imap.once("ready", () => {
imap.end();
resolve({
success: true,
- message: 'IMAP 연결 성공',
+ message: "IMAP 연결 성공",
});
});
- imap.once('error', (err: any) => {
+ imap.once("error", (err: any) => {
reject(err);
});
// 타임아웃 설정 (10초)
const timeout = setTimeout(() => {
imap.end();
- reject(new Error('연결 시간 초과'));
+ reject(new Error("연결 시간 초과"));
}, 10000);
- imap.once('ready', () => {
+ imap.once("ready", () => {
clearTimeout(timeout);
});
@@ -390,11 +574,48 @@ export class MailReceiveBasicService {
} catch (error) {
return {
success: false,
- message: error instanceof Error ? error.message : '알 수 없는 오류',
+ message: error instanceof Error ? error.message : "알 수 없는 오류",
};
}
}
+ /**
+ * 오늘 수신한 메일 수 조회 (통계용)
+ */
+ async getTodayReceivedCount(accountId?: string): Promise {
+ try {
+ const accounts = accountId
+ ? [await mailAccountFileService.getAccountById(accountId)]
+ : await mailAccountFileService.getAllAccounts();
+
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ let totalCount = 0;
+
+ for (const account of accounts) {
+ if (!account) continue;
+
+ try {
+ const mails = await this.fetchMailList(account.id, 100);
+ const todayMails = mails.filter((mail) => {
+ const mailDate = new Date(mail.date);
+ return mailDate >= today;
+ });
+ totalCount += todayMails.length;
+ } catch (error) {
+ // 개별 계정 오류는 무시하고 계속 진행
+ console.error(`계정 ${account.id} 메일 조회 실패:`, error);
+ }
+ }
+
+ return totalCount;
+ } catch (error) {
+ console.error("오늘 수신 메일 수 조회 실패:", error);
+ return 0;
+ }
+ }
+
/**
* 첨부파일 다운로드
*/
@@ -402,50 +623,78 @@ export class MailReceiveBasicService {
accountId: string,
seqno: number,
attachmentIndex: number
- ): Promise<{ filePath: string; filename: string; contentType: string } | null> {
+ ): Promise<{
+ filePath: string;
+ filename: string;
+ contentType: string;
+ } | null> {
const account = await mailAccountFileService.getAccountById(accountId);
if (!account) {
- throw new Error('메일 계정을 찾을 수 없습니다.');
+ 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,
};
return new Promise((resolve, reject) => {
const imap = this.createImapConnection(imapConfig);
- imap.once('ready', () => {
- imap.openBox('INBOX', true, (err: any, box: any) => {
+ imap.once("ready", () => {
+ imap.openBox("INBOX", true, (err: any, box: any) => {
if (err) {
imap.end();
return reject(err);
}
const fetch = imap.seq.fetch(`${seqno}:${seqno}`, {
- bodies: '',
+ bodies: "",
struct: true,
});
- let attachmentResult: { filePath: string; filename: string; contentType: string } | null = null;
+ let attachmentResult: {
+ filePath: string;
+ filename: string;
+ contentType: string;
+ } | null = null;
+ let parsingComplete = false;
- fetch.on('message', (msg: any, seqnum: any) => {
- msg.on('body', (stream: any, info: any) => {
- let buffer = '';
- stream.on('data', (chunk: any) => {
- buffer += chunk.toString('utf8');
+ fetch.on("message", (msg: any, seqnum: any) => {
+ console.log(`📎 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
+
+ msg.on("body", (stream: any, info: any) => {
+ console.log(`📎 메일 본문 스트림 시작`);
+ let buffer = "";
+ stream.on("data", (chunk: any) => {
+ buffer += chunk.toString("utf8");
});
- stream.once('end', async () => {
+ stream.once("end", async () => {
+ console.log(
+ `📎 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`
+ );
try {
const parsed = await simpleParser(buffer);
+ console.log(
+ `📎 파싱 완료 - 첨부파일 개수: ${parsed.attachments?.length || 0}`
+ );
- if (parsed.attachments && parsed.attachments[attachmentIndex]) {
+ if (
+ parsed.attachments &&
+ parsed.attachments[attachmentIndex]
+ ) {
const attachment = parsed.attachments[attachmentIndex];
-
+ console.log(
+ `📎 첨부파일 발견 (index ${attachmentIndex}): ${attachment.filename}`
+ );
+
// 안전한 파일명 생성
const safeFilename = this.sanitizeFilename(
attachment.filename || `attachment-${Date.now()}`
@@ -456,33 +705,63 @@ export class MailReceiveBasicService {
// 파일 저장
await fs.writeFile(filePath, attachment.content);
+ console.log(`📎 파일 저장 완료: ${filePath}`);
attachmentResult = {
filePath,
- filename: attachment.filename || 'unnamed',
- contentType: attachment.contentType || 'application/octet-stream',
+ filename: attachment.filename || "unnamed",
+ contentType:
+ attachment.contentType || "application/octet-stream",
};
+ parsingComplete = true;
+ } else {
+ console.log(
+ `❌ 첨부파일 index ${attachmentIndex}를 찾을 수 없음 (총 ${parsed.attachments?.length || 0}개)`
+ );
+ parsingComplete = true;
}
} catch (parseError) {
- console.error('첨부파일 파싱 오류:', parseError);
+ console.error("첨부파일 파싱 오류:", parseError);
+ parsingComplete = true;
}
});
});
});
- fetch.once('error', (fetchErr: any) => {
+ fetch.once("error", (fetchErr: any) => {
+ console.error("❌ fetch 오류:", fetchErr);
imap.end();
reject(fetchErr);
});
- fetch.once('end', () => {
- imap.end();
- resolve(attachmentResult);
+ fetch.once("end", () => {
+ console.log('📎 fetch.once("end") 호출됨 - 파싱 완료 대기 시작...');
+
+ // 파싱 완료를 기다림 (최대 5초)
+ const checkComplete = setInterval(() => {
+ if (parsingComplete) {
+ console.log(
+ `✅ 파싱 완료 확인 - attachmentResult: ${attachmentResult ? "있음" : "없음"}`
+ );
+ clearInterval(checkComplete);
+ imap.end();
+ resolve(attachmentResult);
+ }
+ }, 100);
+
+ setTimeout(() => {
+ clearInterval(checkComplete);
+ console.log(
+ `⚠️ 타임아웃 - attachmentResult: ${attachmentResult ? "있음" : "없음"}`
+ );
+ imap.end();
+ resolve(attachmentResult);
+ }, 5000);
});
});
});
- imap.once('error', (imapErr: any) => {
+ imap.once("error", (imapErr: any) => {
reject(imapErr);
});
@@ -495,9 +774,8 @@ export class MailReceiveBasicService {
*/
private sanitizeFilename(filename: string): string {
return filename
- .replace(/[^a-zA-Z0-9가-힣.\-_]/g, '_')
- .replace(/_{2,}/g, '_')
+ .replace(/[^a-zA-Z0-9가-힣.\-_]/g, "_")
+ .replace(/_{2,}/g, "_")
.substring(0, 200); // 최대 길이 제한
}
}
-
diff --git a/backend-node/src/services/mailSendSimpleService.ts b/backend-node/src/services/mailSendSimpleService.ts
index 473f3959..188e68c8 100644
--- a/backend-node/src/services/mailSendSimpleService.ts
+++ b/backend-node/src/services/mailSendSimpleService.ts
@@ -7,14 +7,23 @@ 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[]; // 수신자 이메일 배열
+ modifiedTemplateComponents?: any[]; // 🎯 프론트엔드에서 수정된 템플릿 컴포넌트
+ 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 +39,8 @@ class MailSendSimpleService {
* 단일 메일 발송 또는 소규모 발송
*/
async sendMail(request: SendMailRequest): Promise {
+ let htmlContent = ''; // 상위 스코프로 이동
+
try {
// 1. 계정 조회
const account = await mailAccountFileService.getAccountById(request.accountId);
@@ -42,15 +53,29 @@ class MailSendSimpleService {
throw new Error('비활성 상태의 계정입니다.');
}
- // 3. HTML 생성 (템플릿 또는 커스텀)
- let htmlContent = request.customHtml || '';
-
- if (!htmlContent && request.templateId) {
+ // 3. HTML 생성 (템플릿 + 추가 메시지 병합)
+ if (request.templateId) {
+ // 템플릿 사용
const template = await mailTemplateFileService.getTemplateById(request.templateId);
if (!template) {
throw new Error('템플릿을 찾을 수 없습니다.');
}
+
+ // 🎯 수정된 컴포넌트가 있으면 덮어쓰기
+ if (request.modifiedTemplateComponents && request.modifiedTemplateComponents.length > 0) {
+ console.log('✏️ 수정된 템플릿 컴포넌트 사용:', request.modifiedTemplateComponents.length);
+ template.components = request.modifiedTemplateComponents;
+ }
+
htmlContent = this.renderTemplate(template, request.variables);
+
+ // 템플릿 + 추가 메시지 병합
+ if (request.customHtml && request.customHtml.trim()) {
+ htmlContent = this.mergeTemplateAndCustomContent(htmlContent, request.customHtml);
+ }
+ } else {
+ // 직접 작성
+ htmlContent = request.customHtml || '';
}
if (!htmlContent) {
@@ -59,20 +84,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 +114,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 +175,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 +222,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,
@@ -121,13 +276,25 @@ class MailSendSimpleService {
}
/**
- * 템플릿 렌더링 (간단 버전)
+ * 템플릿 렌더링 (일반 메일 양식)
*/
private renderTemplate(
template: any,
variables?: Record
): string {
- let html = '';
+ // 일반적인 메일 레이아웃 (전체 너비, 그림자 없음)
+ let html = `
+
+
+
+
+
+
+
+
+
+
+`;
template.components.forEach((component: any) => {
switch (component.type) {
@@ -136,48 +303,51 @@ 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}
- `;
+ // 버튼은 왼쪽 정렬 (text-align 제거)
+ html += ``;
break;
-
case 'image':
- html += ` `;
+ // 이미지는 왼쪽 정렬
+ html += `
+
+
`;
break;
-
case 'spacer':
- html += `
`;
+ html += `
`;
break;
}
});
- html += '';
+ html += `
+
+
+
+
+
+`;
return html;
}
/**
* 변수 치환
*/
- 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);
@@ -187,20 +357,49 @@ class MailSendSimpleService {
}
/**
- * 스타일 객체를 CSS 문자열로 변환
+ * 템플릿과 추가 메시지 병합
+ * 템플릿 HTML의 body 태그 끝 부분에 추가 메시지를 삽입
*/
- private styleObjectToString(styles?: Record): string {
- if (!styles) return '';
- return Object.entries(styles)
- .map(([key, value]) => `${this.camelToKebab(key)}: ${value}`)
- .join('; ');
- }
+ private mergeTemplateAndCustomContent(templateHtml: string, customContent: string): string {
+ // customContent에 HTML 태그가 없으면 기본 스타일 적용
+ let formattedCustomContent = customContent;
+ if (!customContent.includes('<')) {
+ // 일반 텍스트인 경우 단락으로 변환
+ const paragraphs = customContent
+ .split('\n\n')
+ .filter((p) => p.trim())
+ .map((p) => `${p.replace(/\n/g, ' ')}
`)
+ .join('');
+
+ formattedCustomContent = `
+
+ ${paragraphs}
+
+ `;
+ } else {
+ // 이미 HTML인 경우 구분선만 추가
+ formattedCustomContent = `
+
+ ${customContent}
+
+ `;
+ }
- /**
- * camelCase를 kebab-case로 변환
- */
- private camelToKebab(str: string): string {
- return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
+ //