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 12cefea0..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",
@@ -4247,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",
@@ -6375,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": {
@@ -8040,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",
@@ -8950,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",
diff --git a/backend-node/package.json b/backend-node/package.json
index befdcb15..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",
diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts
index 31f12a32..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";
@@ -73,8 +74,8 @@ 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) => {
@@ -174,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);
diff --git a/backend-node/src/config/multerConfig.ts b/backend-node/src/config/multerConfig.ts
new file mode 100644
index 00000000..e1389030
--- /dev/null
+++ b/backend-node/src/config/multerConfig.ts
@@ -0,0 +1,111 @@
+import multer from 'multer';
+import path from 'path';
+import fs from 'fs';
+
+// 업로드 디렉토리 경로
+const UPLOAD_DIR = path.join(__dirname, '../../uploads/mail-attachments');
+
+// 디렉토리 생성 (없으면)
+if (!fs.existsSync(UPLOAD_DIR)) {
+ fs.mkdirSync(UPLOAD_DIR, { recursive: true });
+}
+
+// 간단한 파일명 정규화 함수 (한글-분석.txt 방식)
+function normalizeFileName(filename: string): string {
+ if (!filename) return filename;
+
+ try {
+ // NFC 정규화만 수행 (복잡한 디코딩 제거)
+ return filename.normalize('NFC');
+ } catch (error) {
+ console.error(`Failed to normalize filename: ${filename}`, error);
+ return filename;
+ }
+}
+
+// 파일 저장 설정
+const storage = multer.diskStorage({
+ destination: (req, file, cb) => {
+ cb(null, UPLOAD_DIR);
+ },
+ filename: (req, file, cb) => {
+ try {
+ // 파일명 정규화 (한글-분석.txt 방식)
+ file.originalname = file.originalname.normalize('NFC');
+
+ console.log('File upload - Processing:', {
+ original: file.originalname,
+ originalHex: Buffer.from(file.originalname).toString('hex'),
+ });
+
+ // UUID + 확장자로 유니크한 파일명 생성
+ const uniqueId = Date.now() + '-' + Math.round(Math.random() * 1e9);
+ const ext = path.extname(file.originalname);
+ const filename = `${uniqueId}${ext}`;
+
+ console.log('Generated filename:', {
+ original: file.originalname,
+ generated: filename,
+ });
+
+ cb(null, filename);
+ } catch (error) {
+ console.error('Filename processing error:', error);
+ const fallbackFilename = `${Date.now()}-${Math.round(Math.random() * 1e9)}_error.tmp`;
+ cb(null, fallbackFilename);
+ }
+ },
+});
+
+// 파일 필터 (허용할 파일 타입)
+const fileFilter = (req: any, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
+ // 파일명 정규화 (fileFilter가 filename보다 먼저 실행되므로 여기서 먼저 처리)
+ try {
+ // NFD를 NFC로 정규화만 수행
+ file.originalname = file.originalname.normalize('NFC');
+ } catch (error) {
+ console.warn('Failed to normalize filename in fileFilter:', error);
+ }
+
+ // 위험한 파일 확장자 차단
+ const dangerousExtensions = ['.exe', '.bat', '.cmd', '.sh', '.ps1', '.msi'];
+ const ext = path.extname(file.originalname).toLowerCase();
+
+ if (dangerousExtensions.includes(ext)) {
+ console.log(`❌ 차단된 파일 타입: ${ext}`);
+ cb(new Error(`보안상의 이유로 ${ext} 파일은 첨부할 수 없습니다.`));
+ return;
+ }
+
+ cb(null, true);
+};
+
+// Multer 설정
+export const uploadMailAttachment = multer({
+ storage,
+ fileFilter,
+ limits: {
+ fileSize: 10 * 1024 * 1024, // 10MB 제한
+ files: 5, // 최대 5개 파일
+ },
+});
+
+// 첨부파일 정보 추출 헬퍼
+export interface AttachmentInfo {
+ filename: string;
+ originalName: string;
+ size: number;
+ path: string;
+ mimetype: string;
+}
+
+export const extractAttachmentInfo = (files: Express.Multer.File[]): AttachmentInfo[] => {
+ return files.map((file) => ({
+ filename: file.filename,
+ originalName: file.originalname,
+ size: file.size,
+ path: file.path,
+ mimetype: file.mimetype,
+ }));
+};
+
diff --git a/backend-node/src/controllers/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/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/services/mailReceiveBasicService.ts b/backend-node/src/services/mailReceiveBasicService.ts
index 9208a852..2c1112a1 100644
--- a/backend-node/src/services/mailReceiveBasicService.ts
+++ b/backend-node/src/services/mailReceiveBasicService.ts
@@ -3,9 +3,11 @@
* IMAP 연결 및 메일 목록 조회
*/
-import * as Imap from 'imap';
+// CommonJS 모듈이므로 require 사용
+const Imap = require('imap');
import { simpleParser } from 'mailparser';
import { mailAccountFileService } from './mailAccountFileService';
+import { encryptionService } from './encryptionService';
import fs from 'fs/promises';
import path from 'path';
@@ -57,6 +59,20 @@ export class MailReceiveBasicService {
}
}
+ /**
+ * SMTP 포트에서 IMAP 포트 추론
+ */
+ private inferImapPort(smtpPort: number, imapPort?: number): number {
+ if (imapPort) return imapPort;
+
+ if (smtpPort === 465 || smtpPort === 587) {
+ return 993; // IMAPS (SSL/TLS)
+ } else if (smtpPort === 25) {
+ return 143; // IMAP (no encryption)
+ }
+ return 993; // 기본값: IMAPS
+ }
+
/**
* IMAP 연결 생성
*/
@@ -80,27 +96,47 @@ export class MailReceiveBasicService {
throw new Error('메일 계정을 찾을 수 없습니다.');
}
+ // 비밀번호 복호화
+ const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
+
+ // IMAP 설정
+ const accountAny = account as any;
const imapConfig: ImapConfig = {
user: account.email,
- password: account.smtpPassword, // 이미 복호화됨
- host: account.smtpHost,
- port: account.smtpPort === 587 ? 993 : account.smtpPort, // SMTP 587 -> IMAP 993
+ password: decryptedPassword,
+ host: accountAny.imapHost || account.smtpHost,
+ port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
tls: true,
};
+ // console.log(`📧 IMAP 연결 시도 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, 이메일: ${imapConfig.user}`);
+
return new Promise((resolve, reject) => {
const imap = this.createImapConnection(imapConfig);
const mails: ReceivedMail[] = [];
+ // 30초 타임아웃 설정
+ const timeout = setTimeout(() => {
+ // console.error('❌ IMAP 연결 타임아웃 (30초)');
+ imap.end();
+ reject(new Error('IMAP 연결 타임아웃'));
+ }, 30000);
+
imap.once('ready', () => {
+ // console.log('✅ IMAP 연결 성공! INBOX 열기 시도...');
+ clearTimeout(timeout);
+
imap.openBox('INBOX', true, (err: any, box: any) => {
if (err) {
+ // console.error('❌ INBOX 열기 실패:', err);
imap.end();
return reject(err);
}
+ // console.log(`📬 INBOX 열림 - 전체 메일 수: ${box.messages.total}`);
const totalMessages = box.messages.total;
if (totalMessages === 0) {
+ // console.log('📭 메일함이 비어있습니다');
imap.end();
return resolve([]);
}
@@ -109,15 +145,23 @@ export class MailReceiveBasicService {
const start = Math.max(1, totalMessages - limit + 1);
const end = totalMessages;
+ // console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`);
const fetch = imap.seq.fetch(`${start}:${end}`, {
bodies: ['HEADER', 'TEXT'],
struct: true,
});
+ // console.log(`📦 fetch 객체 생성 완료`);
+
+ let processedCount = 0;
+ const totalToProcess = end - start + 1;
+
fetch.on('message', (msg: any, seqno: any) => {
+ // console.log(`📬 메일 #${seqno} 처리 시작`);
let header: string = '';
let body: string = '';
let attributes: any = null;
+ let bodiesReceived = 0;
msg.on('body', (stream: any, info: any) => {
let buffer = '';
@@ -130,6 +174,7 @@ export class MailReceiveBasicService {
} else {
body = buffer;
}
+ bodiesReceived++;
});
});
@@ -137,50 +182,88 @@ export class MailReceiveBasicService {
attributes = attrs;
});
- msg.once('end', async () => {
- try {
- const parsed = await simpleParser(header + '\r\n\r\n' + body);
+ msg.once('end', () => {
+ // body 데이터를 모두 받을 때까지 대기
+ const waitForBodies = setInterval(async () => {
+ if (bodiesReceived >= 2 || (header && body)) {
+ clearInterval(waitForBodies);
+
+ try {
+ const parsed = await simpleParser(header + '\r\n\r\n' + body);
- const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from;
- const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to;
+ const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from;
+ const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to;
- const mail: ReceivedMail = {
- id: `${accountId}-${seqno}`,
- messageId: parsed.messageId || `${seqno}`,
- from: fromAddress?.text || 'Unknown',
- to: toAddress?.text || '',
- subject: parsed.subject || '(제목 없음)',
- date: parsed.date || new Date(),
- preview: this.extractPreview(parsed.text || parsed.html || ''),
- isRead: attributes?.flags?.includes('\\Seen') || false,
- hasAttachments: (parsed.attachments?.length || 0) > 0,
- };
+ const mail: ReceivedMail = {
+ id: `${accountId}-${seqno}`,
+ messageId: parsed.messageId || `${seqno}`,
+ from: fromAddress?.text || 'Unknown',
+ to: toAddress?.text || '',
+ subject: parsed.subject || '(제목 없음)',
+ date: parsed.date || new Date(),
+ preview: this.extractPreview(parsed.text || parsed.html || ''),
+ isRead: attributes?.flags?.includes('\\Seen') || false,
+ hasAttachments: (parsed.attachments?.length || 0) > 0,
+ };
- mails.push(mail);
- } catch (parseError) {
- console.error('메일 파싱 오류:', parseError);
- }
+ mails.push(mail);
+ // console.log(`✓ 메일 #${seqno} 파싱 완료 (${mails.length}/${totalToProcess})`);
+ processedCount++;
+ } catch (parseError) {
+ // console.error(`메일 #${seqno} 파싱 오류:`, parseError);
+ processedCount++;
+ }
+ }
+ }, 50);
});
});
fetch.once('error', (fetchErr: any) => {
+ // console.error('❌ 메일 fetch 에러:', fetchErr);
imap.end();
reject(fetchErr);
});
fetch.once('end', () => {
- imap.end();
- // 최신 메일이 위로 오도록 정렬
- mails.sort((a, b) => b.date.getTime() - a.date.getTime());
- resolve(mails);
+ // console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`);
+
+ // 모든 메일 처리가 완료될 때까지 대기
+ const checkComplete = setInterval(() => {
+ // console.log(`⏳ 대기 중 - 처리됨: ${processedCount}/${totalToProcess}, 메일: ${mails.length}개`);
+ if (processedCount >= totalToProcess) {
+ clearInterval(checkComplete);
+ // console.log(`✅ 메일 가져오기 완료 - 총 ${mails.length}개`);
+ imap.end();
+ // 최신 메일이 위로 오도록 정렬
+ mails.sort((a, b) => b.date.getTime() - a.date.getTime());
+ // console.log(`📤 메일 목록 반환: ${mails.length}개`);
+ resolve(mails);
+ }
+ }, 100);
+
+ // 최대 10초 대기
+ setTimeout(() => {
+ clearInterval(checkComplete);
+ // console.log(`⚠️ 타임아웃 - 부분 반환: ${mails.length}/${totalToProcess}개`);
+ imap.end();
+ mails.sort((a, b) => b.date.getTime() - a.date.getTime());
+ resolve(mails);
+ }, 10000);
});
});
});
imap.once('error', (imapErr: any) => {
+ // console.error('❌ IMAP 연결 에러:', imapErr.message || imapErr);
+ clearTimeout(timeout);
reject(imapErr);
});
+ imap.once('end', () => {
+ // console.log('🔌 IMAP 연결 종료');
+ });
+
+ // console.log('🔗 IMAP.connect() 호출...');
imap.connect();
});
}
@@ -206,11 +289,15 @@ export class MailReceiveBasicService {
throw new Error('메일 계정을 찾을 수 없습니다.');
}
+ // 비밀번호 복호화
+ const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
+
+ const accountAny = account as any;
const imapConfig: ImapConfig = {
user: account.email,
- password: account.smtpPassword,
- host: account.smtpHost,
- port: account.smtpPort === 587 ? 993 : account.smtpPort,
+ password: decryptedPassword,
+ host: accountAny.imapHost || account.smtpHost,
+ port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
tls: true,
};
@@ -224,22 +311,36 @@ export class MailReceiveBasicService {
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: '',
struct: true,
});
let mailDetail: MailDetail | null = null;
+ let parsingComplete = false;
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 () => {
+ 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;
@@ -266,21 +367,48 @@ export class MailReceiveBasicService {
size: att.size || 0,
})),
};
+ parsingComplete = true;
} catch (parseError) {
console.error('메일 파싱 오류:', parseError);
+ parsingComplete = true;
}
});
});
+
+ // msg 전체가 처리되었을 때 이벤트
+ msg.once('end', () => {
+ console.log(`📮 메일 메시지 처리 완료 - seqnum: ${seqnum}`);
+ });
});
fetch.once('error', (fetchErr: any) => {
+ console.error(`❌ Fetch 에러:`, fetchErr);
imap.end();
reject(fetchErr);
});
fetch.once('end', () => {
- imap.end();
- resolve(mailDetail);
+ 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);
});
});
});
@@ -302,11 +430,15 @@ export class MailReceiveBasicService {
throw new Error('메일 계정을 찾을 수 없습니다.');
}
+ // 비밀번호 복호화
+ const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
+
+ const accountAny = account as any;
const imapConfig: ImapConfig = {
user: account.email,
- password: account.smtpPassword,
- host: account.smtpHost,
- port: account.smtpPort === 587 ? 993 : account.smtpPort,
+ password: decryptedPassword,
+ host: accountAny.imapHost || account.smtpHost,
+ port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
tls: true,
};
@@ -352,13 +484,19 @@ export class MailReceiveBasicService {
throw new Error('메일 계정을 찾을 수 없습니다.');
}
+ // 비밀번호 복호화
+ const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
+ // console.log(`🔐 IMAP 테스트 - 이메일: ${account.email}, 비밀번호 길이: ${decryptedPassword.length}`);
+
+ const accountAny = account as any;
const imapConfig: ImapConfig = {
user: account.email,
- password: account.smtpPassword,
- host: account.smtpHost,
- port: account.smtpPort === 587 ? 993 : account.smtpPort,
+ password: decryptedPassword,
+ host: accountAny.imapHost || account.smtpHost,
+ port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
tls: true,
};
+ // console.log(`📧 IMAP 설정 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, TLS: ${imapConfig.tls}`);
return new Promise((resolve, reject) => {
const imap = this.createImapConnection(imapConfig);
@@ -395,6 +533,43 @@ export class MailReceiveBasicService {
}
}
+ /**
+ * 오늘 수신한 메일 수 조회 (통계용)
+ */
+ 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;
+ }
+ }
+
/**
* 첨부파일 다운로드
*/
@@ -408,11 +583,15 @@ export class MailReceiveBasicService {
throw new Error('메일 계정을 찾을 수 없습니다.');
}
+ // 비밀번호 복호화
+ const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
+
+ const accountAny = account as any;
const imapConfig: ImapConfig = {
user: account.email,
- password: account.smtpPassword,
- host: account.smtpHost,
- port: account.smtpPort === 587 ? 993 : account.smtpPort,
+ password: decryptedPassword,
+ host: accountAny.imapHost || account.smtpHost,
+ port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
tls: true,
};
@@ -432,19 +611,26 @@ export class MailReceiveBasicService {
});
let attachmentResult: { filePath: string; filename: string; contentType: string } | null = null;
+ let parsingComplete = false;
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 () => {
+ console.log(`📎 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`);
try {
const parsed = await simpleParser(buffer);
+ console.log(`📎 파싱 완료 - 첨부파일 개수: ${parsed.attachments?.length || 0}`);
if (parsed.attachments && parsed.attachments[attachmentIndex]) {
const attachment = parsed.attachments[attachmentIndex];
+ console.log(`📎 첨부파일 발견 (index ${attachmentIndex}): ${attachment.filename}`);
// 안전한 파일명 생성
const safeFilename = this.sanitizeFilename(
@@ -456,28 +642,51 @@ export class MailReceiveBasicService {
// 파일 저장
await fs.writeFile(filePath, attachment.content);
+ console.log(`📎 파일 저장 완료: ${filePath}`);
attachmentResult = {
filePath,
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);
+ parsingComplete = true;
}
});
});
});
fetch.once('error', (fetchErr: any) => {
+ console.error('❌ fetch 오류:', fetchErr);
imap.end();
reject(fetchErr);
});
fetch.once('end', () => {
- imap.end();
- resolve(attachmentResult);
+ 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);
});
});
});
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();
+ //