Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feat/dashboard

This commit is contained in:
dohyeons 2025-10-23 11:25:42 +09:00
commit 900ac4b76e
52 changed files with 4800 additions and 876 deletions

View File

@ -0,0 +1,19 @@
{
"id": "12b583c9-a6b2-4c7f-8340-fd0e700aa32e",
"sentAt": "2025-10-22T05:17:38.303Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "Fwd: ㅏㅣ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄴㅇㄹㄴㅇㄹㄴㅇㄹㅇ리'ㅐㅔ'ㅑ678463ㅎㄱ휼췇흍츄</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border: 1px solid #ccc; padding: 15px; margin: 10px 0; background-color: #f9f9f9;\">\r\n <p><strong>---------- 전달된 메시지 ----------</strong></p>\r\n <p><strong>보낸 사람:</strong> \"이희진\" <zian9227@naver.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:32:34</p>\r\n <p><strong>제목:</strong> ㅏㅣ</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
"status": "success",
"messageId": "<74dbd467-6185-024d-dd60-bf4459ff9ea4@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": [],
"deletedAt": "2025-10-22T06:36:10.876Z"
}

View File

@ -0,0 +1,16 @@
{
"id": "1bb5ebfe-3f6c-4884-a043-161ae3f74f75",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [],
"cc": [],
"bcc": [],
"subject": "Fwd: ㄴㅇㄹㅇㄴㄴㄹ 테스트트트",
"htmlContent": "\n\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n전달된 메일:\n\n보낸사람: \"이희진\" <zian9227@naver.com>\n날짜: 2025. 10. 22. 오후 4:24:54\n제목: ㄴㅇㄹㅇㄴㄴㄹ\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nㄹㅇㄴㄹㅇㄴㄹㅇㄴ\n",
"sentAt": "2025-10-22T07:49:50.811Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T07:49:50.811Z",
"deletedAt": "2025-10-22T07:50:14.211Z"
}

View File

@ -0,0 +1,18 @@
{
"id": "1d997eeb-3d61-427d-8b54-119d4372b9b3",
"sentAt": "2025-10-22T07:13:30.905Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "Fwd: ㄴ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">전달히야야양</p><p style=\"margin: 0 0 16px 0; line-height: 1.6;\"><br>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━<br>전달된 메일:</p><p style=\"margin: 0 0 16px 0; line-height: 1.6;\">보낸사람: \"이희진\" <zian9227@naver.com><br>날짜: 2025. 10. 22. 오후 12:58:15<br>제목: ㄴ<br>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</p><p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄴㅇㄹㄴㅇㄹㄴㅇㄹ<br></p>\r\n </div>\r\n ",
"status": "success",
"messageId": "<d20cd501-04a4-bbe6-8b50-7f43e19bd70a@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,18 @@
{
"id": "331d95d6-3a13-4657-bc75-ab0811712eb8",
"sentAt": "2025-10-22T07:18:18.240Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "ㅁㄴㅇㄹㅁㄴㅇㄹ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㄴㅇㄹㄴㅇㄹ</p>\r\n </div>\r\n ",
"status": "success",
"messageId": "<d4923c0d-f692-7d1d-d1b0-3b9e1e6cbab5@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,19 @@
{
"id": "375f2326-ca86-468a-bfc3-2d4c3825577b",
"sentAt": "2025-10-22T04:57:39.706Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"\"이희진\" <zian9227@naver.com>"
],
"subject": "Re: ㅏㅣ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㅇㄴㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㄴㅁㅇㄹ</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"이희진\" <zian9227@naver.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:32:34</p>\r\n <p><strong>제목:</strong> ㅏㅣ</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
"status": "success",
"messageId": "<f085efa6-2668-0293-57de-88b1e7009dd1@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": [],
"deletedAt": "2025-10-22T07:11:04.666Z"
}

View File

@ -0,0 +1,16 @@
{
"id": "386e334a-df76-440c-ae8a-9bf06982fdc8",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [],
"cc": [],
"bcc": [],
"subject": "Fwd: ㄴ",
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>---------- 전달된 메일 ----------</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" &lt;zian9227@naver.com&gt;</p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 12:58:15</p>\n <p><strong>제목:</strong> ㄴ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\">ㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n</p>\n </div>\n ",
"sentAt": "2025-10-22T07:04:27.192Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T07:04:57.280Z",
"deletedAt": "2025-10-22T07:50:17.136Z"
}

View File

@ -0,0 +1,18 @@
{
"id": "3d411dc4-69a6-4236-b878-9693dff881be",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"cc": [],
"bcc": [],
"subject": "Re: ㄴ",
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>원본 메일:</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 12:58:15</p>\n <p><strong>제목:</strong> ㄴ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\">undefined</p>\n </div>\n ",
"sentAt": "2025-10-22T06:56:51.060Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T06:56:51.060Z",
"deletedAt": "2025-10-22T07:50:22.989Z"
}

View File

@ -0,0 +1,16 @@
{
"id": "3e30a264-8431-44c7-96ef-eed551e66a11",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [],
"cc": [],
"bcc": [],
"subject": "Fwd: ㄴ",
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>---------- 전달된 메일 ----------</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 12:58:15</p>\n <p><strong>제목:</strong> ㄴ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\"></p>\n </div>\n ",
"sentAt": "2025-10-22T06:57:53.335Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T07:00:23.394Z",
"deletedAt": "2025-10-22T07:50:20.510Z"
}

View File

@ -0,0 +1,16 @@
{
"id": "4a32bab5-364e-4037-bb00-31d2905824db",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [],
"cc": [],
"bcc": [],
"subject": "테스트 마지가",
"htmlContent": "ㅁㄴㅇㄹ",
"sentAt": "2025-10-22T07:49:29.948Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T07:49:29.948Z",
"deletedAt": "2025-10-22T07:50:12.374Z"
}

View File

@ -0,0 +1,16 @@
{
"id": "5bfb2acd-023a-4865-a738-2900179db5fb",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [],
"cc": [],
"bcc": [],
"subject": "Fwd: ㄴ",
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>---------- 전달된 메일 ----------</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 12:58:15</p>\n <p><strong>제목:</strong> ㄴ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\">ㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n</p>\n </div>\n ",
"sentAt": "2025-10-22T07:03:09.080Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T07:03:39.150Z",
"deletedAt": "2025-10-22T07:50:19.035Z"
}

View File

@ -0,0 +1,18 @@
{
"id": "683c1323-1895-403a-bb9a-4e111a8909f6",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"cc": [],
"bcc": [],
"subject": "Re: ㄴ",
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>원본 메일:</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 12:58:15</p>\n <p><strong>제목:</strong> ㄴ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\">undefined</p>\n </div>\n ",
"sentAt": "2025-10-22T06:54:55.097Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T06:54:55.097Z",
"deletedAt": "2025-10-22T07:50:24.672Z"
}

View File

@ -0,0 +1,16 @@
{
"id": "7bed27d5-dae4-4ba8-85d0-c474c4fb907a",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [],
"cc": [],
"bcc": [],
"subject": "Fwd: ㅏㅣ",
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>---------- 전달된 메일 ----------</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:32:34</p>\n <p><strong>제목:</strong> ㅏㅣ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n undefined\n </div>\n ",
"sentAt": "2025-10-22T06:41:52.984Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T06:46:23.051Z",
"deletedAt": "2025-10-22T07:50:29.124Z"
}

View File

@ -0,0 +1,18 @@
{
"id": "84ee9619-49ff-4f61-a7fa-0bb0b0b7199a",
"sentAt": "2025-10-22T04:27:51.044Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"\"이희진\" <zian9227@naver.com>"
],
"subject": "Re: ㅅㄷㄴㅅ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">야야야야야야야야ㅑㅇ야ㅑㅇ</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"이희진\" <zian9227@naver.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:03:03</p>\r\n <p><strong>제목:</strong> ㅅㄷㄴㅅ</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
"status": "success",
"messageId": "<5fa451ff-7d29-7da4-ce56-ca7391c147af@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,13 @@
{
"id": "8990ea86-3112-4e7c-b3e0-8b494181c4e0",
"accountName": "",
"accountEmail": "",
"to": [],
"subject": "",
"htmlContent": "",
"sentAt": "2025-10-22T06:17:31.379Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T06:17:31.379Z",
"deletedAt": "2025-10-22T07:50:30.736Z"
}

View File

@ -0,0 +1,18 @@
{
"id": "89a32ace-f39b-44fa-b614-c65d96548f92",
"sentAt": "2025-10-22T03:49:48.461Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "Fwd: 기상청 API허브 회원가입 인증번호",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\"><br> <br/><br/><br> <div style=\"border: 1px solid #ccc; padding: 15px; margin: 10px 0; background-color: #f9f9f9;\"><br> <p><strong>---------- 전달된 메시지 ----------</strong></p><br> <p><strong>보낸 사람:</strong> \"기상청 API허브\" <noreply@apihube.kma.go.kr></p><br> <p><strong>날짜:</strong> 2025. 10. 13. 오후 4:26:45</p><br> <p><strong>제목:</strong> 기상청 API허브 회원가입 인증번호</p><br> <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" /><br> undefined<br> </div><br> </p>\r\n </div>\r\n ",
"status": "success",
"messageId": "<9b36ce56-4ef1-cf0c-1f39-2c73bcb521da@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,13 @@
{
"id": "99703f2c-740c-492e-a866-a04289a9b699",
"accountName": "",
"accountEmail": "",
"to": [],
"subject": "",
"htmlContent": "",
"sentAt": "2025-10-22T06:20:08.450Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T06:20:08.450Z",
"deletedAt": "2025-10-22T06:36:07.797Z"
}

View File

@ -0,0 +1,19 @@
{
"id": "9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e",
"sentAt": "2025-10-22T04:31:17.175Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"\"이희진\" <zian9227@naver.com>"
],
"subject": "Re: ㅅㄷㄴㅅ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">배불르고 졸린데 커피먹으니깐 졸린건 괜찮아졋고 배불러서 물배찼당아아아아</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"이희진\" <zian9227@naver.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:03:03</p>\r\n <p><strong>제목:</strong> ㅅㄷㄴㅅ</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
"status": "success",
"messageId": "<0f215ba8-a1e4-8c5a-f43f-962f0717c161@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": [],
"deletedAt": "2025-10-22T07:11:10.245Z"
}

View File

@ -0,0 +1,18 @@
{
"id": "9d0b9fcf-cabf-4053-b6b6-6e110add22de",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"cc": [],
"bcc": [],
"subject": "Re: ㅏㅣ",
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>원본 메일:</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:32:34</p>\n <p><strong>제목:</strong> ㅏㅣ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\">undefined</p>\n </div>\n ",
"sentAt": "2025-10-22T06:50:04.224Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T06:50:04.224Z",
"deletedAt": "2025-10-22T07:50:26.224Z"
}

View File

@ -0,0 +1,18 @@
{
"id": "a638f7d0-ee31-47fa-9f72-de66ef31ea44",
"sentAt": "2025-10-22T07:21:13.723Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "ㄹㅇㄴㅁㄹㅇㄴㅁ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄹㅇㄴㅁㄹㅇㄴㅁㅇㄹㅇㄴㅁ</p>\r\n </div>\r\n ",
"status": "success",
"messageId": "<5ea07d02-78bf-a655-8289-bcbd8eaf7741@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -0,0 +1,18 @@
{
"id": "b293e530-2b2d-4b8a-8081-d103fab5a13f",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"cc": [],
"bcc": [],
"subject": "Re: 수신메일확인용",
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>원본 메일:</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 13. 오전 10:40:30</p>\n <p><strong>제목:</strong> 수신메일확인용</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n undefined\n </div>\n ",
"sentAt": "2025-10-22T06:47:53.815Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T06:48:53.876Z",
"deletedAt": "2025-10-22T07:50:27.706Z"
}

View File

@ -0,0 +1,16 @@
{
"id": "cf892a77-1998-4165-bb9d-b390451465b2",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [],
"cc": [],
"bcc": [],
"subject": "Fwd: ㄴ",
"htmlContent": "\n\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n전달된 메일:\n\n보낸사람: \"이희진\" <zian9227@naver.com>\n날짜: 2025. 10. 22. 오후 12:58:15\n제목: ㄴ\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n",
"sentAt": "2025-10-22T07:06:11.620Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T07:07:11.749Z",
"deletedAt": "2025-10-22T07:50:15.739Z"
}

View File

@ -0,0 +1,13 @@
{
"id": "e3501abc-cd31-4b20-bb02-3c7ddbe54eb8",
"accountName": "",
"accountEmail": "",
"to": [],
"subject": "",
"htmlContent": "",
"sentAt": "2025-10-22T06:15:02.128Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T06:15:02.128Z",
"deletedAt": "2025-10-22T07:08:43.543Z"
}

View File

@ -0,0 +1,27 @@
{
"id": "e93848a8-6901-44c4-b4db-27c8d2aeb8dd",
"sentAt": "2025-10-22T04:28:42.686Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"\"권은아\" <chna8137s@gmail.com>"
],
"subject": "Re: 매우 졸린 오후예요",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">호홋 답장 기능을 구현했다죵<br>얼른 퇴근하고 싪네여</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"권은아\" <chna8137s@gmail.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:10:37</p>\r\n <p><strong>제목:</strong> 매우 졸린 오후예요</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
"attachments": [
{
"filename": "test용 이미지2.png",
"originalName": "test용 이미지2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1761107318152-717716316.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<19981423-259b-0a50-e76d-23c860692c16@wace.me>",
"accepted": [
"chna8137s@gmail.com"
],
"rejected": []
}

View File

@ -0,0 +1,16 @@
{
"id": "eb92ed00-cc4f-4cc8-94c9-9bef312d16db",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [],
"cc": [],
"bcc": [],
"subject": "메일 임시저장 테스트 4",
"htmlContent": "asd",
"sentAt": "2025-10-22T06:21:40.019Z",
"status": "draft",
"isDraft": true,
"updatedAt": "2025-10-22T06:21:40.019Z",
"deletedAt": "2025-10-22T06:36:05.306Z"
}

View File

@ -0,0 +1,18 @@
{
"id": "fcea6149-a098-4212-aa00-baef0cc083d6",
"sentAt": "2025-10-22T04:24:54.126Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"\"DHS\" <ddhhss0603@gmail.com>"
],
"subject": "Re: 안녕하세여",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">어떻게 가는지 궁금한데 이따가 화면 보여주세영</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"DHS\" <ddhhss0603@gmail.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:09:49</p>\r\n <p><strong>제목:</strong> 안녕하세여</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
"status": "success",
"messageId": "<c24b04f0-b958-5e0b-4cc7-2bff30f23c2c@wace.me>",
"accepted": [
"ddhhss0603@gmail.com"
],
"rejected": []
}

View File

@ -0,0 +1,28 @@
{
"id": "fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082",
"sentAt": "2025-10-22T04:29:14.738Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"\"이희진\" <zian9227@naver.com>"
],
"subject": "Re: ㅅㄷㄴㅅ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㄴㅇㄹㄴㅇㄹ</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"이희진\" <zian9227@naver.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:03:03</p>\r\n <p><strong>제목:</strong> ㅅㄷㄴㅅ</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
"attachments": [
{
"filename": "test용 이미지2.png",
"originalName": "test용 이미지2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1761107350246-298369766.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<e68a0501-f79a-8713-a625-e882f711b30d@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": [],
"deletedAt": "2025-10-22T07:11:12.907Z"
}

View File

@ -31,6 +31,8 @@
"nodemailer": "^6.10.1", "nodemailer": "^6.10.1",
"oracledb": "^6.9.0", "oracledb": "^6.9.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"quill": "^2.0.3",
"react-quill": "^2.0.0",
"redis": "^4.6.10", "redis": "^4.6.10",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"winston": "^3.11.0" "winston": "^3.11.0"
@ -3433,6 +3435,21 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/quill": {
"version": "1.3.10",
"resolved": "https://registry.npmjs.org/@types/quill/-/quill-1.3.10.tgz",
"integrity": "sha512-IhW3fPW+bkt9MLNlycw8u8fWb7oO7W5URC9MfZYHBlA24rex9rs23D5DETChu1zvgVdc5ka64ICjJOgQMr6Shw==",
"license": "MIT",
"dependencies": {
"parchment": "^1.1.2"
}
},
"node_modules/@types/quill/node_modules/parchment": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
"license": "BSD-3-Clause"
},
"node_modules/@types/range-parser": { "node_modules/@types/range-parser": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
@ -4437,6 +4454,24 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.0",
"es-define-property": "^1.0.0",
"get-intrinsic": "^1.2.4",
"set-function-length": "^1.2.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/call-bind-apply-helpers": { "node_modules/call-bind-apply-helpers": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@ -4610,6 +4645,15 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/clone": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/cluster-key-slot": { "node_modules/cluster-key-slot": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
@ -4944,6 +4988,26 @@
} }
} }
}, },
"node_modules/deep-equal": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz",
"integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==",
"license": "MIT",
"dependencies": {
"is-arguments": "^1.1.1",
"is-date-object": "^1.0.5",
"is-regex": "^1.1.4",
"object-is": "^1.1.5",
"object-keys": "^1.1.1",
"regexp.prototype.flags": "^1.5.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/deep-is": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -4988,6 +5052,23 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/define-lazy-prop": { "node_modules/define-lazy-prop": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
@ -5000,6 +5081,23 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/define-properties": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
"license": "MIT",
"dependencies": {
"define-data-property": "^1.0.1",
"has-property-descriptors": "^1.0.0",
"object-keys": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delayed-stream": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@ -5554,6 +5652,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/events": { "node_modules/events": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@ -5689,6 +5793,12 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -5696,6 +5806,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-diff": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"license": "Apache-2.0"
},
"node_modules/fast-glob": { "node_modules/fast-glob": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@ -5997,6 +6113,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/functions-have-names": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
"integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/generate-function": { "node_modules/generate-function": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
@ -6249,6 +6374,18 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": { "node_modules/has-symbols": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@ -6563,6 +6700,22 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/is-arguments": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-arrayish": { "node_modules/is-arrayish": {
"version": "0.2.1", "version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
@ -6599,6 +6752,22 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-date-object": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
"integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-docker": { "node_modules/is-docker": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
@ -6701,6 +6870,24 @@
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"gopd": "^1.2.0",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-stream": { "node_modules/is-stream": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
@ -7658,6 +7845,24 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
},
"node_modules/lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
"license": "MIT"
},
"node_modules/lodash.includes": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@ -7670,6 +7875,13 @@
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/lodash.isinteger": { "node_modules/lodash.isinteger": {
"version": "4.0.4", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
@ -8292,6 +8504,31 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/object-is": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -8436,6 +8673,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/parchment": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
"license": "BSD-3-Clause"
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -8960,6 +9203,35 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/quill": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
"license": "BSD-3-Clause",
"dependencies": {
"eventemitter3": "^5.0.1",
"lodash-es": "^4.17.21",
"parchment": "^3.0.0",
"quill-delta": "^5.1.0"
},
"engines": {
"npm": ">=8.2.3"
}
},
"node_modules/quill-delta": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
"license": "MIT",
"dependencies": {
"fast-diff": "^1.3.0",
"lodash.clonedeep": "^4.5.0",
"lodash.isequal": "^4.5.0"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/range-parser": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@ -9003,6 +9275,67 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-quill": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-quill/-/react-quill-2.0.0.tgz",
"integrity": "sha512-4qQtv1FtCfLgoD3PXAur5RyxuUbPXQGOHgTlFie3jtxp43mXDtzCKaOgQ3mLyZfi1PUlyjycfivKelFhy13QUg==",
"license": "MIT",
"dependencies": {
"@types/quill": "^1.3.10",
"lodash": "^4.17.4",
"quill": "^1.3.7"
},
"peerDependencies": {
"react": "^16 || ^17 || ^18",
"react-dom": "^16 || ^17 || ^18"
}
},
"node_modules/react-quill/node_modules/eventemitter3": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
"license": "MIT"
},
"node_modules/react-quill/node_modules/fast-diff": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
"integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
"license": "Apache-2.0"
},
"node_modules/react-quill/node_modules/parchment": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
"license": "BSD-3-Clause"
},
"node_modules/react-quill/node_modules/quill": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
"integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
"license": "BSD-3-Clause",
"dependencies": {
"clone": "^2.1.1",
"deep-equal": "^1.0.1",
"eventemitter3": "^2.0.3",
"extend": "^3.0.2",
"parchment": "^1.1.4",
"quill-delta": "^3.6.2"
}
},
"node_modules/react-quill/node_modules/quill-delta": {
"version": "3.6.3",
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
"integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
"license": "MIT",
"dependencies": {
"deep-equal": "^1.0.1",
"extend": "^3.0.2",
"fast-diff": "1.1.2"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/readable-stream": { "node_modules/readable-stream": {
"version": "2.3.8", "version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
@ -9054,6 +9387,26 @@
"@redis/time-series": "1.1.0" "@redis/time-series": "1.1.0"
} }
}, },
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
"integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.8",
"define-properties": "^1.2.1",
"es-errors": "^1.3.0",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"set-function-name": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@ -9325,6 +9678,38 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.4",
"gopd": "^1.0.1",
"has-property-descriptors": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/set-function-name": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
"integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
"es-errors": "^1.3.0",
"functions-have-names": "^1.2.3",
"has-property-descriptors": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/setprototypeof": { "node_modules/setprototypeof": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",

View File

@ -45,6 +45,8 @@
"nodemailer": "^6.10.1", "nodemailer": "^6.10.1",
"oracledb": "^6.9.0", "oracledb": "^6.9.0",
"pg": "^8.16.3", "pg": "^8.16.3",
"quill": "^2.0.3",
"react-quill": "^2.0.0",
"redis": "^4.6.10", "redis": "^4.6.10",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"winston": "^3.11.0" "winston": "^3.11.0"

View File

@ -31,6 +31,7 @@ import layoutRoutes from "./routes/layoutRoutes";
import mailTemplateFileRoutes from "./routes/mailTemplateFileRoutes"; import mailTemplateFileRoutes from "./routes/mailTemplateFileRoutes";
import mailAccountFileRoutes from "./routes/mailAccountFileRoutes"; import mailAccountFileRoutes from "./routes/mailAccountFileRoutes";
import mailSendSimpleRoutes from "./routes/mailSendSimpleRoutes"; import mailSendSimpleRoutes from "./routes/mailSendSimpleRoutes";
import mailSentHistoryRoutes from "./routes/mailSentHistoryRoutes";
import mailReceiveBasicRoutes from "./routes/mailReceiveBasicRoutes"; import mailReceiveBasicRoutes from "./routes/mailReceiveBasicRoutes";
import dataRoutes from "./routes/dataRoutes"; import dataRoutes from "./routes/dataRoutes";
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes"; import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
@ -186,6 +187,7 @@ app.use("/api/layouts", layoutRoutes);
app.use("/api/mail/accounts", mailAccountFileRoutes); // 파일 기반 계정 app.use("/api/mail/accounts", mailAccountFileRoutes); // 파일 기반 계정
app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿 app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿
app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송 app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송
app.use("/api/mail/sent", mailSentHistoryRoutes); // 메일 발송 이력
app.use("/api/mail/receive", mailReceiveBasicRoutes); // 메일 수신 app.use("/api/mail/receive", mailReceiveBasicRoutes); // 메일 수신
app.use("/api/screen", screenStandardRoutes); app.use("/api/screen", screenStandardRoutes);
app.use("/api/data", dataRoutes); app.use("/api/data", dataRoutes);
@ -270,6 +272,28 @@ app.listen(PORT, HOST, async () => {
} catch (error) { } catch (error) {
logger.error(`❌ 리스크/알림 자동 갱신 시작 실패:`, error); logger.error(`❌ 리스크/알림 자동 갱신 시작 실패:`, error);
} }
// 메일 자동 삭제 (30일 지난 삭제된 메일) - 매일 새벽 2시 실행
try {
const cron = await import("node-cron");
const { mailSentHistoryService } = await import(
"./services/mailSentHistoryService"
);
cron.schedule("0 2 * * *", async () => {
try {
logger.info("🗑️ 30일 지난 삭제된 메일 자동 삭제 시작...");
const deletedCount = await mailSentHistoryService.cleanupOldDeletedMails();
logger.info(`✅ 30일 지난 메일 ${deletedCount}개 자동 삭제 완료`);
} catch (error) {
logger.error("❌ 메일 자동 삭제 실패:", error);
}
});
logger.info(`⏰ 메일 자동 삭제 스케줄러가 시작되었습니다. (매일 새벽 2시)`);
} catch (error) {
logger.error(`❌ 메일 자동 삭제 스케줄러 시작 실패:`, error);
}
}); });
export default app; export default app;

View File

@ -18,11 +18,11 @@ export class MailReceiveBasicController {
*/ */
async getMailList(req: Request, res: Response) { async getMailList(req: Request, res: Response) {
try { try {
console.log('📬 메일 목록 조회 요청:', { // console.log('📬 메일 목록 조회 요청:', {
params: req.params, // params: req.params,
path: req.path, // path: req.path,
originalUrl: req.originalUrl // originalUrl: req.originalUrl
}); // });
const { accountId } = req.params; const { accountId } = req.params;
const limit = parseInt(req.query.limit as string) || 50; const limit = parseInt(req.query.limit as string) || 50;
@ -49,11 +49,11 @@ export class MailReceiveBasicController {
*/ */
async getMailDetail(req: Request, res: Response) { async getMailDetail(req: Request, res: Response) {
try { try {
console.log('🔍 메일 상세 조회 요청:', { // console.log('🔍 메일 상세 조회 요청:', {
params: req.params, // params: req.params,
path: req.path, // path: req.path,
originalUrl: req.originalUrl // originalUrl: req.originalUrl
}); // });
const { accountId, seqno } = req.params; const { accountId, seqno } = req.params;
const seqnoNumber = parseInt(seqno, 10); const seqnoNumber = parseInt(seqno, 10);
@ -121,39 +121,39 @@ export class MailReceiveBasicController {
*/ */
async downloadAttachment(req: Request, res: Response) { async downloadAttachment(req: Request, res: Response) {
try { try {
console.log('📎🎯 컨트롤러 downloadAttachment 진입'); // console.log('📎🎯 컨트롤러 downloadAttachment 진입');
const { accountId, seqno, index } = req.params; const { accountId, seqno, index } = req.params;
console.log(`📎 파라미터: accountId=${accountId}, seqno=${seqno}, index=${index}`); // console.log(`📎 파라미터: accountId=${accountId}, seqno=${seqno}, index=${index}`);
const seqnoNumber = parseInt(seqno, 10); const seqnoNumber = parseInt(seqno, 10);
const indexNumber = parseInt(index, 10); const indexNumber = parseInt(index, 10);
if (isNaN(seqnoNumber) || isNaN(indexNumber)) { if (isNaN(seqnoNumber) || isNaN(indexNumber)) {
console.log('❌ 유효하지 않은 파라미터'); // console.log('❌ 유효하지 않은 파라미터');
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: '유효하지 않은 파라미터입니다.', message: '유효하지 않은 파라미터입니다.',
}); });
} }
console.log('📎 서비스 호출 시작...'); // console.log('📎 서비스 호출 시작...');
const result = await this.mailReceiveService.downloadAttachment( const result = await this.mailReceiveService.downloadAttachment(
accountId, accountId,
seqnoNumber, seqnoNumber,
indexNumber indexNumber
); );
console.log(`📎 서비스 호출 완료: result=${result ? '있음' : '없음'}`); // console.log(`📎 서비스 호출 완료: result=${result ? '있음' : '없음'}`);
if (!result) { if (!result) {
console.log('❌ 첨부파일을 찾을 수 없음'); // console.log('❌ 첨부파일을 찾을 수 없음');
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
message: '첨부파일을 찾을 수 없습니다.', message: '첨부파일을 찾을 수 없습니다.',
}); });
} }
console.log(`📎 파일 다운로드 시작: ${result.filename}`); // console.log(`📎 파일 다운로드 시작: ${result.filename}`);
console.log(`📎 파일 경로: ${result.filePath}`); // console.log(`📎 파일 경로: ${result.filePath}`);
// 파일 다운로드 // 파일 다운로드
res.download(result.filePath, result.filename, (err) => { res.download(result.filePath, result.filename, (err) => {
@ -217,5 +217,35 @@ export class MailReceiveBasicController {
}); });
} }
} }
/**
* DELETE /api/mail/receive/:accountId/:seqno
* IMAP
*/
async deleteMail(req: Request, res: Response) {
try {
const { accountId, seqno } = req.params;
const seqnoNumber = parseInt(seqno, 10);
if (isNaN(seqnoNumber)) {
return res.status(400).json({
success: false,
message: '유효하지 않은 메일 번호입니다.',
});
}
const result = await this.mailReceiveService.deleteMail(accountId, seqnoNumber);
return res.status(200).json(result);
} catch (error: unknown) {
console.error('메일 삭제 실패:', error);
return res.status(500).json({
success: false,
message: error instanceof Error ? error.message : '메일 삭제 실패',
});
}
}
} }
export const mailReceiveBasicController = new MailReceiveBasicController();

View File

@ -7,14 +7,14 @@ export class MailSendSimpleController {
*/ */
async sendMail(req: Request, res: Response) { async sendMail(req: Request, res: Response) {
try { try {
console.log('📧 메일 발송 요청 수신:', { // console.log('📧 메일 발송 요청 수신:', {
accountId: req.body.accountId, // accountId: req.body.accountId,
to: req.body.to, // to: req.body.to,
cc: req.body.cc, // cc: req.body.cc,
bcc: req.body.bcc, // bcc: req.body.bcc,
subject: req.body.subject, // subject: req.body.subject,
attachments: req.files ? (req.files as Express.Multer.File[]).length : 0, // attachments: req.files ? (req.files as Express.Multer.File[]).length : 0,
}); // });
// FormData에서 JSON 문자열 파싱 // FormData에서 JSON 문자열 파싱
const accountId = req.body.accountId; const accountId = req.body.accountId;
@ -31,7 +31,7 @@ export class MailSendSimpleController {
// 필수 파라미터 검증 // 필수 파라미터 검증
if (!accountId || !to || !Array.isArray(to) || to.length === 0) { if (!accountId || !to || !Array.isArray(to) || to.length === 0) {
console.log('❌ 필수 파라미터 누락'); // console.log('❌ 필수 파라미터 누락');
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: '계정 ID와 수신자 이메일이 필요합니다.', message: '계정 ID와 수신자 이메일이 필요합니다.',
@ -63,9 +63,9 @@ export class MailSendSimpleController {
if (req.body.fileNames) { if (req.body.fileNames) {
try { try {
parsedFileNames = JSON.parse(req.body.fileNames); parsedFileNames = JSON.parse(req.body.fileNames);
console.log('📎 프론트엔드에서 받은 파일명들:', parsedFileNames); // console.log('📎 프론트엔드에서 받은 파일명들:', parsedFileNames);
} catch (e) { } catch (e) {
console.warn('파일명 파싱 실패, multer originalname 사용'); // console.warn('파일명 파싱 실패, multer originalname 사용');
} }
} }
@ -83,10 +83,10 @@ export class MailSendSimpleController {
}); });
}); });
console.log('📎 최종 첨부파일 정보:', attachments.map(a => ({ // console.log('📎 최종 첨부파일 정보:', attachments.map(a => ({
filename: a.filename, // filename: a.filename,
path: a.path.split('/').pop() // path: a.path.split('/').pop()
}))); // })));
} }
// 메일 발송 // 메일 발송
@ -125,6 +125,63 @@ export class MailSendSimpleController {
} }
} }
/**
*
*/
async sendBulkMail(req: Request, res: Response) {
try {
const { accountId, templateId, customHtml, subject, recipients } = req.body;
// 필수 파라미터 검증
if (!accountId || !subject || !recipients || !Array.isArray(recipients)) {
return res.status(400).json({
success: false,
message: '필수 파라미터가 누락되었습니다.',
});
}
// 템플릿 또는 직접 작성 중 하나는 있어야 함
if (!templateId && !customHtml) {
return res.status(400).json({
success: false,
message: '템플릿 또는 메일 내용 중 하나는 필수입니다.',
});
}
if (recipients.length === 0) {
return res.status(400).json({
success: false,
message: '수신자가 없습니다.',
});
}
// console.log(`📧 대량 발송 요청: ${recipients.length}명`);
// 대량 발송 실행
const result = await mailSendSimpleService.sendBulkMail({
accountId,
templateId, // 선택
customHtml, // 선택
subject,
recipients,
});
return res.json({
success: true,
data: result,
message: `${result.success}/${result.total} 건 발송 완료`,
});
} catch (error: unknown) {
const err = error as Error;
console.error('❌ 대량 발송 오류:', err);
return res.status(500).json({
success: false,
message: '대량 발송 중 오류가 발생했습니다.',
error: err.message,
});
}
}
/** /**
* SMTP * SMTP
*/ */

View File

@ -11,12 +11,14 @@ export class MailSentHistoryController {
page: req.query.page ? parseInt(req.query.page as string) : undefined, page: req.query.page ? parseInt(req.query.page as string) : undefined,
limit: req.query.limit ? parseInt(req.query.limit as string) : undefined, limit: req.query.limit ? parseInt(req.query.limit as string) : undefined,
searchTerm: req.query.searchTerm as string | undefined, searchTerm: req.query.searchTerm as string | undefined,
status: req.query.status as 'success' | 'failed' | 'all' | undefined, status: req.query.status as 'success' | 'failed' | 'draft' | 'all' | undefined,
accountId: req.query.accountId as string | undefined, accountId: req.query.accountId as string | undefined,
startDate: req.query.startDate as string | undefined, startDate: req.query.startDate as string | undefined,
endDate: req.query.endDate as string | undefined, endDate: req.query.endDate as string | undefined,
sortBy: req.query.sortBy as 'sentAt' | 'subject' | undefined, sortBy: req.query.sortBy as 'sentAt' | 'subject' | 'updatedAt' | undefined,
sortOrder: req.query.sortOrder as 'asc' | 'desc' | undefined, sortOrder: req.query.sortOrder as 'asc' | 'desc' | undefined,
includeDeleted: req.query.includeDeleted === 'true',
onlyDeleted: req.query.onlyDeleted === 'true',
}; };
const result = await mailSentHistoryService.getSentMailList(query); const result = await mailSentHistoryService.getSentMailList(query);
@ -112,6 +114,144 @@ export class MailSentHistoryController {
} }
} }
/**
* (Draft)
*/
async saveDraft(req: Request, res: Response) {
try {
const draft = await mailSentHistoryService.saveDraft(req.body);
return res.json({
success: true,
data: draft,
message: '임시 저장되었습니다.',
});
} catch (error: unknown) {
const err = error as Error;
console.error('임시 저장 실패:', err);
return res.status(500).json({
success: false,
message: '임시 저장 중 오류가 발생했습니다.',
error: err.message,
});
}
}
/**
*
*/
async updateDraft(req: Request, res: Response) {
try {
const { id } = req.params;
if (!id) {
return res.status(400).json({
success: false,
message: '임시 저장 ID가 필요합니다.',
});
}
const updated = await mailSentHistoryService.updateDraft(id, req.body);
if (!updated) {
return res.status(404).json({
success: false,
message: '임시 저장을 찾을 수 없습니다.',
});
}
return res.json({
success: true,
data: updated,
message: '임시 저장이 업데이트되었습니다.',
});
} catch (error: unknown) {
const err = error as Error;
console.error('임시 저장 업데이트 실패:', err);
return res.status(500).json({
success: false,
message: '임시 저장 업데이트 중 오류가 발생했습니다.',
error: err.message,
});
}
}
/**
*
*/
async restoreMail(req: Request, res: Response) {
try {
const { id } = req.params;
if (!id) {
return res.status(400).json({
success: false,
message: '메일 ID가 필요합니다.',
});
}
const success = await mailSentHistoryService.restoreMail(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 permanentlyDelete(req: Request, res: Response) {
try {
const { id } = req.params;
if (!id) {
return res.status(400).json({
success: false,
message: '메일 ID가 필요합니다.',
});
}
const success = await mailSentHistoryService.permanentlyDeleteMail(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,
});
}
}
/** /**
* *
*/ */
@ -134,6 +274,117 @@ export class MailSentHistoryController {
}); });
} }
} }
/**
*
*/
async bulkDelete(req: Request, res: Response) {
try {
const { ids } = req.body;
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({
success: false,
message: '삭제할 메일 ID 목록이 필요합니다.',
});
}
const results = await Promise.allSettled(
ids.map((id: string) => mailSentHistoryService.deleteSentMail(id))
);
const successCount = results.filter((r) => r.status === 'fulfilled' && r.value).length;
const failCount = results.length - successCount;
return res.json({
success: true,
message: `${successCount}개 메일 삭제 완료 (실패: ${failCount}개)`,
data: { successCount, failCount },
});
} catch (error: unknown) {
const err = error as Error;
console.error('일괄 삭제 실패:', err);
return res.status(500).json({
success: false,
message: '일괄 삭제 중 오류가 발생했습니다.',
error: err.message,
});
}
}
/**
*
*/
async bulkPermanentDelete(req: Request, res: Response) {
try {
const { ids } = req.body;
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({
success: false,
message: '영구 삭제할 메일 ID 목록이 필요합니다.',
});
}
const results = await Promise.allSettled(
ids.map((id: string) => mailSentHistoryService.permanentlyDeleteMail(id))
);
const successCount = results.filter((r) => r.status === 'fulfilled' && r.value).length;
const failCount = results.length - successCount;
return res.json({
success: true,
message: `${successCount}개 메일 영구 삭제 완료 (실패: ${failCount}개)`,
data: { successCount, failCount },
});
} catch (error: unknown) {
const err = error as Error;
console.error('일괄 영구 삭제 실패:', err);
return res.status(500).json({
success: false,
message: '일괄 영구 삭제 중 오류가 발생했습니다.',
error: err.message,
});
}
}
/**
*
*/
async bulkRestore(req: Request, res: Response) {
try {
const { ids } = req.body;
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({
success: false,
message: '복구할 메일 ID 목록이 필요합니다.',
});
}
const results = await Promise.allSettled(
ids.map((id: string) => mailSentHistoryService.restoreMail(id))
);
const successCount = results.filter((r) => r.status === 'fulfilled' && r.value).length;
const failCount = results.length - successCount;
return res.json({
success: true,
message: `${successCount}개 메일 복구 완료 (실패: ${failCount}개)`,
data: { successCount, failCount },
});
} 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(); export const mailSentHistoryController = new MailSentHistoryController();

View File

@ -27,6 +27,9 @@ router.get('/:accountId/:seqno/attachment/:index', (req, res) => {
// 메일 읽음 표시 - 구체적인 경로 // 메일 읽음 표시 - 구체적인 경로
router.post('/:accountId/:seqno/mark-read', (req, res) => controller.markAsRead(req, res)); router.post('/:accountId/:seqno/mark-read', (req, res) => controller.markAsRead(req, res));
// 메일 삭제 - 구체적인 경로
router.delete('/:accountId/:seqno', (req, res) => controller.deleteMail(req, res));
// 메일 상세 조회 - /:accountId보다 먼저 정의해야 함 // 메일 상세 조회 - /:accountId보다 먼저 정의해야 함
router.get('/:accountId/:seqno', (req, res) => controller.getMailDetail(req, res)); router.get('/:accountId/:seqno', (req, res) => controller.getMailDetail(req, res));

View File

@ -15,6 +15,9 @@ router.post(
(req, res) => mailSendSimpleController.sendMail(req, res) (req, res) => mailSendSimpleController.sendMail(req, res)
); );
// POST /api/mail/send/bulk - 대량 메일 발송
router.post('/bulk', (req, res) => mailSendSimpleController.sendBulkMail(req, res));
// POST /api/mail/send/test-connection - SMTP 연결 테스트 // POST /api/mail/send/test-connection - SMTP 연결 테스트
router.post('/test-connection', (req, res) => mailSendSimpleController.testConnection(req, res)); router.post('/test-connection', (req, res) => mailSendSimpleController.testConnection(req, res));

View File

@ -7,16 +7,37 @@ const router = Router();
// 모든 라우트에 인증 미들웨어 적용 // 모든 라우트에 인증 미들웨어 적용
router.use(authenticateToken); router.use(authenticateToken);
// GET /api/mail/sent/statistics - 통계 조회 (⚠️ 반드시 /:id 보다 먼저 정의)
router.get('/statistics', (req, res) => mailSentHistoryController.getStatistics(req, res));
// GET /api/mail/sent - 발송 이력 목록 조회 // GET /api/mail/sent - 발송 이력 목록 조회
router.get('/', (req, res) => mailSentHistoryController.getList(req, res)); router.get('/', (req, res) => mailSentHistoryController.getList(req, res));
// GET /api/mail/sent/statistics - 통계 조회 // POST /api/mail/sent/draft - 임시 저장 (Draft)
router.get('/statistics', (req, res) => mailSentHistoryController.getStatistics(req, res)); router.post('/draft', (req, res) => mailSentHistoryController.saveDraft(req, res));
// PUT /api/mail/sent/draft/:id - 임시 저장 업데이트
router.put('/draft/:id', (req, res) => mailSentHistoryController.updateDraft(req, res));
// POST /api/mail/sent/bulk/delete - 일괄 삭제
router.post('/bulk/delete', (req, res) => mailSentHistoryController.bulkDelete(req, res));
// POST /api/mail/sent/bulk/permanent-delete - 일괄 영구 삭제
router.post('/bulk/permanent-delete', (req, res) => mailSentHistoryController.bulkPermanentDelete(req, res));
// POST /api/mail/sent/bulk/restore - 일괄 복구
router.post('/bulk/restore', (req, res) => mailSentHistoryController.bulkRestore(req, res));
// POST /api/mail/sent/:id/restore - 메일 복구
router.post('/:id/restore', (req, res) => mailSentHistoryController.restoreMail(req, res));
// DELETE /api/mail/sent/:id/permanent - 메일 영구 삭제
router.delete('/:id/permanent', (req, res) => mailSentHistoryController.permanentlyDelete(req, res));
// GET /api/mail/sent/:id - 특정 발송 이력 상세 조회 // GET /api/mail/sent/:id - 특정 발송 이력 상세 조회
router.get('/:id', (req, res) => mailSentHistoryController.getById(req, res)); router.get('/:id', (req, res) => mailSentHistoryController.getById(req, res));
// DELETE /api/mail/sent/:id - 발송 이력 삭제 // DELETE /api/mail/sent/:id - 발송 이력 삭제 (Soft Delete)
router.delete('/:id', (req, res) => mailSentHistoryController.deleteById(req, res)); router.delete('/:id', (req, res) => mailSentHistoryController.deleteById(req, res));
export default router; export default router;

View File

@ -88,6 +88,9 @@ export class MailReceiveBasicService {
port: config.port, port: config.port,
tls: config.tls, tls: config.tls,
tlsOptions: { rejectUnauthorized: false }, tlsOptions: { rejectUnauthorized: false },
authTimeout: 30000, // 인증 타임아웃 30초
connTimeout: 30000, // 연결 타임아웃 30초
keepalive: true,
}); });
} }
@ -116,7 +119,7 @@ export class MailReceiveBasicService {
tls: true, tls: true,
}; };
// console.log(`📧 IMAP 연결 시도 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, 이메일: ${imapConfig.user}`); // // console.log(`📧 IMAP 연결 시도 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, 이메일: ${imapConfig.user}`);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const imap = this.createImapConnection(imapConfig); const imap = this.createImapConnection(imapConfig);
@ -130,7 +133,7 @@ export class MailReceiveBasicService {
}, 30000); }, 30000);
imap.once("ready", () => { imap.once("ready", () => {
// console.log('✅ IMAP 연결 성공! INBOX 열기 시도...'); // // console.log('✅ IMAP 연결 성공! INBOX 열기 시도...');
clearTimeout(timeout); clearTimeout(timeout);
imap.openBox("INBOX", true, (err: any, box: any) => { imap.openBox("INBOX", true, (err: any, box: any) => {
@ -140,10 +143,10 @@ export class MailReceiveBasicService {
return reject(err); return reject(err);
} }
// console.log(`📬 INBOX 열림 - 전체 메일 수: ${box.messages.total}`); // // console.log(`📬 INBOX 열림 - 전체 메일 수: ${box.messages.total}`);
const totalMessages = box.messages.total; const totalMessages = box.messages.total;
if (totalMessages === 0) { if (totalMessages === 0) {
// console.log('📭 메일함이 비어있습니다'); // // console.log('📭 메일함이 비어있습니다');
imap.end(); imap.end();
return resolve([]); return resolve([]);
} }
@ -152,19 +155,19 @@ export class MailReceiveBasicService {
const start = Math.max(1, totalMessages - limit + 1); const start = Math.max(1, totalMessages - limit + 1);
const end = totalMessages; const end = totalMessages;
// console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`); // // console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`);
const fetch = imap.seq.fetch(`${start}:${end}`, { const fetch = imap.seq.fetch(`${start}:${end}`, {
bodies: ["HEADER", "TEXT"], bodies: ["HEADER", "TEXT"],
struct: true, struct: true,
}); });
// console.log(`📦 fetch 객체 생성 완료`); // // console.log(`📦 fetch 객체 생성 완료`);
let processedCount = 0; let processedCount = 0;
const totalToProcess = end - start + 1; const totalToProcess = end - start + 1;
fetch.on("message", (msg: any, seqno: any) => { fetch.on("message", (msg: any, seqno: any) => {
// console.log(`📬 메일 #${seqno} 처리 시작`); // // console.log(`📬 메일 #${seqno} 처리 시작`);
let header: string = ""; let header: string = "";
let body: string = ""; let body: string = "";
let attributes: any = null; let attributes: any = null;
@ -222,7 +225,7 @@ export class MailReceiveBasicService {
}; };
mails.push(mail); mails.push(mail);
// console.log(`✓ 메일 #${seqno} 파싱 완료 (${mails.length}/${totalToProcess})`); // // console.log(`✓ 메일 #${seqno} 파싱 완료 (${mails.length}/${totalToProcess})`);
processedCount++; processedCount++;
} catch (parseError) { } catch (parseError) {
// console.error(`메일 #${seqno} 파싱 오류:`, parseError); // console.error(`메일 #${seqno} 파싱 오류:`, parseError);
@ -240,18 +243,18 @@ export class MailReceiveBasicService {
}); });
fetch.once("end", () => { fetch.once("end", () => {
// console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`); // // console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`);
// 모든 메일 처리가 완료될 때까지 대기 // 모든 메일 처리가 완료될 때까지 대기
const checkComplete = setInterval(() => { const checkComplete = setInterval(() => {
// console.log(`⏳ 대기 중 - 처리됨: ${processedCount}/${totalToProcess}, 메일: ${mails.length}개`); // // console.log(`⏳ 대기 중 - 처리됨: ${processedCount}/${totalToProcess}, 메일: ${mails.length}개`);
if (processedCount >= totalToProcess) { if (processedCount >= totalToProcess) {
clearInterval(checkComplete); clearInterval(checkComplete);
// console.log(`✅ 메일 가져오기 완료 - 총 ${mails.length}개`); // // console.log(`✅ 메일 가져오기 완료 - 총 ${mails.length}개`);
imap.end(); imap.end();
// 최신 메일이 위로 오도록 정렬 // 최신 메일이 위로 오도록 정렬
mails.sort((a, b) => b.date.getTime() - a.date.getTime()); mails.sort((a, b) => b.date.getTime() - a.date.getTime());
// console.log(`📤 메일 목록 반환: ${mails.length}개`); // // console.log(`📤 메일 목록 반환: ${mails.length}개`);
resolve(mails); resolve(mails);
} }
}, 100); }, 100);
@ -259,7 +262,7 @@ export class MailReceiveBasicService {
// 최대 10초 대기 // 최대 10초 대기
setTimeout(() => { setTimeout(() => {
clearInterval(checkComplete); clearInterval(checkComplete);
// console.log(`⚠️ 타임아웃 - 부분 반환: ${mails.length}/${totalToProcess}개`); // // console.log(`⚠️ 타임아웃 - 부분 반환: ${mails.length}/${totalToProcess}개`);
imap.end(); imap.end();
mails.sort((a, b) => b.date.getTime() - a.date.getTime()); mails.sort((a, b) => b.date.getTime() - a.date.getTime());
resolve(mails); resolve(mails);
@ -275,10 +278,10 @@ export class MailReceiveBasicService {
}); });
imap.once("end", () => { imap.once("end", () => {
// console.log('🔌 IMAP 연결 종료'); // // console.log('🔌 IMAP 연결 종료');
}); });
// console.log('🔗 IMAP.connect() 호출...'); // // console.log('🔗 IMAP.connect() 호출...');
imap.connect(); imap.connect();
}); });
} }
@ -329,9 +332,9 @@ export class MailReceiveBasicService {
return reject(err); return reject(err);
} }
console.log( // console.log(
`📬 INBOX 정보 - 전체 메일: ${box.messages.total}, 요청한 seqno: ${seqno}` // `📬 INBOX 정보 - 전체 메일: ${box.messages.total}, 요청한 seqno: ${seqno}`
); // );
if (seqno > box.messages.total || seqno < 1) { if (seqno > box.messages.total || seqno < 1) {
console.error( console.error(
@ -350,21 +353,21 @@ export class MailReceiveBasicService {
let parsingComplete = false; let parsingComplete = false;
fetch.on("message", (msg: any, seqnum: any) => { fetch.on("message", (msg: any, seqnum: any) => {
console.log(`📨 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`); // console.log(`📨 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
msg.on("body", (stream: any, info: any) => { msg.on("body", (stream: any, info: any) => {
console.log(`📝 메일 본문 스트림 시작 - which: ${info.which}`); // console.log(`📝 메일 본문 스트림 시작 - which: ${info.which}`);
let buffer = ""; let buffer = "";
stream.on("data", (chunk: any) => { stream.on("data", (chunk: any) => {
buffer += chunk.toString("utf8"); buffer += chunk.toString("utf8");
}); });
stream.once("end", async () => { stream.once("end", async () => {
console.log( // console.log(
`✅ 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}` // `✅ 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`
); // );
try { try {
const parsed = await simpleParser(buffer); const parsed = await simpleParser(buffer);
console.log(`✅ 메일 파싱 완료 - 제목: ${parsed.subject}`); // console.log(`✅ 메일 파싱 완료 - 제목: ${parsed.subject}`);
const fromAddress = Array.isArray(parsed.from) const fromAddress = Array.isArray(parsed.from)
? parsed.from[0] ? parsed.from[0]
@ -412,7 +415,7 @@ export class MailReceiveBasicService {
// msg 전체가 처리되었을 때 이벤트 // msg 전체가 처리되었을 때 이벤트
msg.once("end", () => { msg.once("end", () => {
console.log(`📮 메일 메시지 처리 완료 - seqnum: ${seqnum}`); // console.log(`📮 메일 메시지 처리 완료 - seqnum: ${seqnum}`);
}); });
}); });
@ -423,15 +426,15 @@ export class MailReceiveBasicService {
}); });
fetch.once("end", () => { fetch.once("end", () => {
console.log(`🏁 Fetch 종료 - parsingComplete: ${parsingComplete}`); // console.log(`🏁 Fetch 종료 - parsingComplete: ${parsingComplete}`);
// 비동기 파싱이 완료될 때까지 대기 // 비동기 파싱이 완료될 때까지 대기
const waitForParsing = setInterval(() => { const waitForParsing = setInterval(() => {
if (parsingComplete) { if (parsingComplete) {
clearInterval(waitForParsing); clearInterval(waitForParsing);
console.log( // console.log(
`✅ 파싱 완료 대기 종료 - mailDetail이 ${mailDetail ? "존재함" : "null"}` // `✅ 파싱 완료 대기 종료 - mailDetail이 ${mailDetail ? "존재함" : "null"}`
); // );
imap.end(); imap.end();
resolve(mailDetail); resolve(mailDetail);
} }
@ -474,29 +477,47 @@ export class MailReceiveBasicService {
const decryptedPassword = encryptionService.decrypt(account.smtpPassword); const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
const accountAny = account as any; const accountAny = account as any;
const imapPort = accountAny.imapPort || this.inferImapPort(account.smtpPort);
const imapConfig: ImapConfig = { const imapConfig: ImapConfig = {
user: account.email, user: account.email,
password: decryptedPassword, password: decryptedPassword,
host: accountAny.imapHost || account.smtpHost, host: accountAny.imapHost || account.smtpHost,
port: this.inferImapPort(account.smtpPort, accountAny.imapPort), port: imapPort,
tls: true, tls: imapPort === 993, // 993 포트면 TLS 사용
}; };
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const imap = this.createImapConnection(imapConfig); const imap = this.createImapConnection(imapConfig);
// 타임아웃 설정
const timeout = setTimeout(() => {
console.error('❌ IMAP 읽음 표시 타임아웃 (30초)');
imap.end();
reject(new Error("IMAP 연결 타임아웃"));
}, 30000);
imap.once("ready", () => { imap.once("ready", () => {
clearTimeout(timeout);
// console.log(`🔗 IMAP 연결 성공 - 읽음 표시 시작 (seqno=${seqno})`);
// false로 변경: 쓰기 가능 모드로 INBOX 열기
imap.openBox("INBOX", false, (err: any, box: any) => { imap.openBox("INBOX", false, (err: any, box: any) => {
if (err) { if (err) {
console.error('❌ INBOX 열기 실패:', err);
imap.end(); imap.end();
return reject(err); return reject(err);
} }
// console.log(`📬 INBOX 열림 (쓰기 가능 모드)`);
imap.seq.addFlags(seqno, ["\\Seen"], (flagErr: any) => { imap.seq.addFlags(seqno, ["\\Seen"], (flagErr: any) => {
imap.end(); imap.end();
if (flagErr) { if (flagErr) {
console.error("❌ 읽음 플래그 설정 실패:", flagErr);
reject(flagErr); reject(flagErr);
} else { } else {
// console.log("✅ 읽음 플래그 설정 성공 - seqno:", seqno);
resolve({ resolve({
success: true, success: true,
message: "메일을 읽음으로 표시했습니다.", message: "메일을 읽음으로 표시했습니다.",
@ -507,9 +528,16 @@ export class MailReceiveBasicService {
}); });
imap.once("error", (imapErr: any) => { imap.once("error", (imapErr: any) => {
clearTimeout(timeout);
console.error('❌ IMAP 에러:', imapErr);
reject(imapErr); reject(imapErr);
}); });
imap.once("end", () => {
clearTimeout(timeout);
});
// console.log(`🔌 IMAP 연결 시도 중... (host=${imapConfig.host}, port=${imapConfig.port})`);
imap.connect(); imap.connect();
}); });
} }
@ -528,7 +556,7 @@ export class MailReceiveBasicService {
// 비밀번호 복호화 // 비밀번호 복호화
const decryptedPassword = encryptionService.decrypt(account.smtpPassword); const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
// console.log(`🔐 IMAP 테스트 - 이메일: ${account.email}, 비밀번호 길이: ${decryptedPassword.length}`); // // console.log(`🔐 IMAP 테스트 - 이메일: ${account.email}, 비밀번호 길이: ${decryptedPassword.length}`);
const accountAny = account as any; const accountAny = account as any;
const imapConfig: ImapConfig = { const imapConfig: ImapConfig = {
@ -538,7 +566,7 @@ export class MailReceiveBasicService {
port: this.inferImapPort(account.smtpPort, accountAny.imapPort), port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
tls: true, tls: true,
}; };
// console.log(`📧 IMAP 설정 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, TLS: ${imapConfig.tls}`); // // console.log(`📧 IMAP 설정 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, TLS: ${imapConfig.tls}`);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const imap = this.createImapConnection(imapConfig); const imap = this.createImapConnection(imapConfig);
@ -664,32 +692,32 @@ export class MailReceiveBasicService {
let parsingComplete = false; let parsingComplete = false;
fetch.on("message", (msg: any, seqnum: any) => { fetch.on("message", (msg: any, seqnum: any) => {
console.log(`📎 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`); // console.log(`📎 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
msg.on("body", (stream: any, info: any) => { msg.on("body", (stream: any, info: any) => {
console.log(`📎 메일 본문 스트림 시작`); // console.log(`📎 메일 본문 스트림 시작`);
let buffer = ""; let buffer = "";
stream.on("data", (chunk: any) => { stream.on("data", (chunk: any) => {
buffer += chunk.toString("utf8"); buffer += chunk.toString("utf8");
}); });
stream.once("end", async () => { stream.once("end", async () => {
console.log( // console.log(
`📎 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}` // `📎 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`
); // );
try { try {
const parsed = await simpleParser(buffer); const parsed = await simpleParser(buffer);
console.log( // console.log(
`📎 파싱 완료 - 첨부파일 개수: ${parsed.attachments?.length || 0}` // `📎 파싱 완료 - 첨부파일 개수: ${parsed.attachments?.length || 0}`
); // );
if ( if (
parsed.attachments && parsed.attachments &&
parsed.attachments[attachmentIndex] parsed.attachments[attachmentIndex]
) { ) {
const attachment = parsed.attachments[attachmentIndex]; const attachment = parsed.attachments[attachmentIndex];
console.log( // console.log(
`📎 첨부파일 발견 (index ${attachmentIndex}): ${attachment.filename}` // `📎 첨부파일 발견 (index ${attachmentIndex}): ${attachment.filename}`
); // );
// 안전한 파일명 생성 // 안전한 파일명 생성
const safeFilename = this.sanitizeFilename( const safeFilename = this.sanitizeFilename(
@ -701,7 +729,7 @@ export class MailReceiveBasicService {
// 파일 저장 // 파일 저장
await fs.writeFile(filePath, attachment.content); await fs.writeFile(filePath, attachment.content);
console.log(`📎 파일 저장 완료: ${filePath}`); // console.log(`📎 파일 저장 완료: ${filePath}`);
attachmentResult = { attachmentResult = {
filePath, filePath,
@ -711,9 +739,9 @@ export class MailReceiveBasicService {
}; };
parsingComplete = true; parsingComplete = true;
} else { } else {
console.log( // console.log(
`❌ 첨부파일 index ${attachmentIndex}를 찾을 수 없음 (총 ${parsed.attachments?.length || 0}개)` // `❌ 첨부파일 index ${attachmentIndex}를 찾을 수 없음 (총 ${parsed.attachments?.length || 0}개)`
); // );
parsingComplete = true; parsingComplete = true;
} }
} catch (parseError) { } catch (parseError) {
@ -731,14 +759,14 @@ export class MailReceiveBasicService {
}); });
fetch.once("end", () => { fetch.once("end", () => {
console.log('📎 fetch.once("end") 호출됨 - 파싱 완료 대기 시작...'); // console.log('📎 fetch.once("end") 호출됨 - 파싱 완료 대기 시작...');
// 파싱 완료를 기다림 (최대 5초) // 파싱 완료를 기다림 (최대 5초)
const checkComplete = setInterval(() => { const checkComplete = setInterval(() => {
if (parsingComplete) { if (parsingComplete) {
console.log( // console.log(
`✅ 파싱 완료 확인 - attachmentResult: ${attachmentResult ? "있음" : "없음"}` // `✅ 파싱 완료 확인 - attachmentResult: ${attachmentResult ? "있음" : "없음"}`
); // );
clearInterval(checkComplete); clearInterval(checkComplete);
imap.end(); imap.end();
resolve(attachmentResult); resolve(attachmentResult);
@ -747,9 +775,9 @@ export class MailReceiveBasicService {
setTimeout(() => { setTimeout(() => {
clearInterval(checkComplete); clearInterval(checkComplete);
console.log( // console.log(
`⚠️ 타임아웃 - attachmentResult: ${attachmentResult ? "있음" : "없음"}` // `⚠️ 타임아웃 - attachmentResult: ${attachmentResult ? "있음" : "없음"}`
); // );
imap.end(); imap.end();
resolve(attachmentResult); resolve(attachmentResult);
}, 5000); }, 5000);
@ -774,4 +802,96 @@ export class MailReceiveBasicService {
.replace(/_{2,}/g, "_") .replace(/_{2,}/g, "_")
.substring(0, 200); // 최대 길이 제한 .substring(0, 200); // 최대 길이 제한
} }
/**
* IMAP ( )
*/
async deleteMail(accountId: string, seqno: number): Promise<{ success: boolean; message: string }> {
const account = await mailAccountFileService.getAccountById(accountId);
if (!account) {
throw new Error("메일 계정을 찾을 수 없습니다.");
}
// 비밀번호 복호화
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
// IMAP 설정 (타입 캐스팅)
const accountAny = account as any;
const imapPort = accountAny.imapPort || this.inferImapPort(account.smtpPort);
const config: ImapConfig = {
user: account.smtpUsername || account.email,
password: decryptedPassword,
host: accountAny.imapHost || account.smtpHost,
port: imapPort,
tls: imapPort === 993, // 993 포트면 TLS 사용, 143이면 사용 안함
};
return new Promise((resolve, reject) => {
const imap = this.createImapConnection(config);
// 30초 타임아웃 설정
const timeout = setTimeout(() => {
console.error('❌ IMAP 메일 삭제 타임아웃 (30초)');
imap.end();
reject(new Error("IMAP 연결 타임아웃"));
}, 30000);
imap.once("ready", () => {
clearTimeout(timeout);
// console.log(`🔗 IMAP 연결 성공 - 메일 삭제 시작 (seqno=${seqno})`);
imap.openBox("INBOX", false, (err: any) => {
if (err) {
console.error('❌ INBOX 열기 실패:', err);
imap.end();
return reject(err);
}
// 메일을 삭제 플래그로 표시 (seq.addFlags 사용)
imap.seq.addFlags(seqno, ["\\Deleted"], (flagErr: any) => {
if (flagErr) {
console.error('❌ 삭제 플래그 추가 실패:', flagErr);
imap.end();
return reject(flagErr);
}
// console.log(`✓ 삭제 플래그 추가 완료 (seqno=${seqno})`);
// 삭제 플래그가 표시된 메일을 영구 삭제 (실제로는 휴지통으로 이동)
imap.expunge((expungeErr: any) => {
imap.end();
if (expungeErr) {
console.error('❌ expunge 실패:', expungeErr);
return reject(expungeErr);
}
// console.log(`🗑️ 메일 삭제 완료: seqno=${seqno}`);
resolve({
success: true,
message: "메일이 삭제되었습니다.",
});
});
});
});
});
imap.once("error", (imapErr: any) => {
clearTimeout(timeout);
console.error('❌ IMAP 에러:', imapErr);
reject(imapErr);
});
imap.once("end", () => {
clearTimeout(timeout);
});
// console.log(`🔌 IMAP 연결 시도 중... (host=${config.host}, port=${config.port})`);
imap.connect();
});
}
} }
export const mailReceiveBasicService = new MailReceiveBasicService();

View File

@ -34,6 +34,29 @@ export interface SendMailResult {
error?: string; error?: string;
} }
export interface BulkSendRequest {
accountId: string;
templateId?: string; // 템플릿 ID (선택)
subject: string;
customHtml?: string; // 직접 작성한 HTML (선택)
recipients: Array<{
email: string;
variables?: Record<string, string>; // 템플릿 사용 시에만 필요
}>;
}
export interface BulkSendResult {
total: number;
success: number;
failed: number;
results: Array<{
email: string;
success: boolean;
messageId?: string;
error?: string;
}>;
}
class MailSendSimpleService { class MailSendSimpleService {
/** /**
* *
@ -63,7 +86,7 @@ class MailSendSimpleService {
// 🎯 수정된 컴포넌트가 있으면 덮어쓰기 // 🎯 수정된 컴포넌트가 있으면 덮어쓰기
if (request.modifiedTemplateComponents && request.modifiedTemplateComponents.length > 0) { if (request.modifiedTemplateComponents && request.modifiedTemplateComponents.length > 0) {
console.log('✏️ 수정된 템플릿 컴포넌트 사용:', request.modifiedTemplateComponents.length); // console.log('✏️ 수정된 템플릿 컴포넌트 사용:', request.modifiedTemplateComponents.length);
template.components = request.modifiedTemplateComponents; template.components = request.modifiedTemplateComponents;
} }
@ -84,15 +107,15 @@ class MailSendSimpleService {
// 4. 비밀번호 복호화 // 4. 비밀번호 복호화
const decryptedPassword = encryptionService.decrypt(account.smtpPassword); const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
// console.log('🔐 비밀번호 복호화 완료'); // // console.log('🔐 비밀번호 복호화 완료');
// console.log('🔐 암호화된 비밀번호 (일부):', account.smtpPassword.substring(0, 30) + '...'); // // console.log('🔐 암호화된 비밀번호 (일부):', account.smtpPassword.substring(0, 30) + '...');
// console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length); // // console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
// 5. SMTP 연결 생성 // 5. SMTP 연결 생성
// 포트 465는 SSL/TLS를 사용해야 함 // 포트 465는 SSL/TLS를 사용해야 함
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false); const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
// console.log('📧 SMTP 연결 설정:', { // // console.log('📧 SMTP 연결 설정:', {
// host: account.smtpHost, // host: account.smtpHost,
// port: account.smtpPort, // port: account.smtpPort,
// secure: isSecure, // secure: isSecure,
@ -112,7 +135,7 @@ class MailSendSimpleService {
greetingTimeout: 30000, greetingTimeout: 30000,
}); });
console.log('📧 메일 발송 시도 중...'); // console.log('📧 메일 발송 시도 중...');
// 6. 메일 발송 (CC, BCC, 첨부파일 지원) // 6. 메일 발송 (CC, BCC, 첨부파일 지원)
const mailOptions: any = { const mailOptions: any = {
@ -125,13 +148,13 @@ class MailSendSimpleService {
// 참조(CC) 추가 // 참조(CC) 추가
if (request.cc && request.cc.length > 0) { if (request.cc && request.cc.length > 0) {
mailOptions.cc = request.cc.join(', '); mailOptions.cc = request.cc.join(', ');
// console.log('📧 참조(CC):', request.cc); // // console.log('📧 참조(CC):', request.cc);
} }
// 숨은참조(BCC) 추가 // 숨은참조(BCC) 추가
if (request.bcc && request.bcc.length > 0) { if (request.bcc && request.bcc.length > 0) {
mailOptions.bcc = request.bcc.join(', '); mailOptions.bcc = request.bcc.join(', ');
// console.log('🔒 숨은참조(BCC):', request.bcc); // // console.log('🔒 숨은참조(BCC):', request.bcc);
} }
// 첨부파일 추가 (한글 파일명 인코딩 처리) // 첨부파일 추가 (한글 파일명 인코딩 처리)
@ -163,17 +186,17 @@ class MailSendSimpleService {
} }
}; };
}); });
console.log('📎 첨부파일 (원본):', request.attachments.map((a: any) => a.filename.replace(/^\d+-\d+_/, ''))); // console.log('📎 첨부파일 (원본):', request.attachments.map((a: any) => a.filename.replace(/^\d+-\d+_/, '')));
console.log('📎 첨부파일 (인코딩):', mailOptions.attachments.map((a: any) => a.filename)); // console.log('📎 첨부파일 (인코딩):', mailOptions.attachments.map((a: any) => a.filename));
} }
const info = await transporter.sendMail(mailOptions); const info = await transporter.sendMail(mailOptions);
console.log('✅ 메일 발송 성공:', { // console.log('✅ 메일 발송 성공:', {
messageId: info.messageId, // messageId: info.messageId,
accepted: info.accepted, // accepted: info.accepted,
rejected: info.rejected, // rejected: info.rejected,
}); // });
// 발송 이력 저장 (성공) // 발송 이력 저장 (성공)
try { try {
@ -402,6 +425,73 @@ class MailSendSimpleService {
} }
} }
/**
* ( )
*/
async sendBulkMail(request: BulkSendRequest): Promise<BulkSendResult> {
const results: Array<{
email: string;
success: boolean;
messageId?: string;
error?: string;
}> = [];
let successCount = 0;
let failedCount = 0;
// console.log(`📧 대량 발송 시작: ${request.recipients.length}명`);
// 순차 발송 (너무 빠르면 스팸으로 분류될 수 있음)
for (const recipient of request.recipients) {
try {
const result = await this.sendMail({
accountId: request.accountId,
templateId: request.templateId, // 템플릿이 있으면 사용
customHtml: request.customHtml, // 직접 작성한 HTML이 있으면 사용
to: [recipient.email],
subject: request.subject,
variables: recipient.variables || {}, // 템플릿 사용 시에만 필요
});
if (result.success) {
successCount++;
results.push({
email: recipient.email,
success: true,
messageId: result.messageId,
});
} else {
failedCount++;
results.push({
email: recipient.email,
success: false,
error: result.error || '발송 실패',
});
}
} catch (error: unknown) {
const err = error as Error;
failedCount++;
results.push({
email: recipient.email,
success: false,
error: err.message,
});
}
// 발송 간격 (500ms) - 스팸 방지
await new Promise((resolve) => setTimeout(resolve, 500));
}
// console.log(`✅ 대량 발송 완료: 성공 ${successCount}, 실패 ${failedCount}`);
return {
total: request.recipients.length,
success: successCount,
failed: failedCount,
results,
};
}
/** /**
* SMTP * SMTP
*/ */
@ -414,13 +504,13 @@ class MailSendSimpleService {
// 비밀번호 복호화 // 비밀번호 복호화
const decryptedPassword = encryptionService.decrypt(account.smtpPassword); const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
// console.log('🔐 테스트용 비밀번호 복호화 완료'); // // console.log('🔐 테스트용 비밀번호 복호화 완료');
// console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length); // // console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
// 포트 465는 SSL/TLS를 사용해야 함 // 포트 465는 SSL/TLS를 사용해야 함
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false); const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
// console.log('🧪 SMTP 연결 테스트 시작:', { // // console.log('🧪 SMTP 연결 테스트 시작:', {
// host: account.smtpHost, // host: account.smtpHost,
// port: account.smtpPort, // port: account.smtpPort,
// secure: isSecure, // secure: isSecure,
@ -443,7 +533,7 @@ class MailSendSimpleService {
// 연결 테스트 // 연결 테스트
await transporter.verify(); await transporter.verify();
console.log('✅ SMTP 연결 테스트 성공'); // console.log('✅ SMTP 연결 테스트 성공');
return { success: true, message: 'SMTP 연결이 성공했습니다.' }; return { success: true, message: 'SMTP 연결이 성공했습니다.' };
} catch (error) { } catch (error) {
const err = error as Error; const err = error as Error;

View File

@ -53,7 +53,7 @@ class MailSentHistoryService {
mode: 0o644, mode: 0o644,
}); });
console.log("발송 이력 저장:", history.id); // console.log("발송 이력 저장:", history.id);
} catch (error) { } catch (error) {
console.error("발송 이력 저장 실패:", error); console.error("발송 이력 저장 실패:", error);
// 파일 저장 실패해도 history 객체는 반환 (메일 발송은 성공했으므로) // 파일 저장 실패해도 history 객체는 반환 (메일 발송은 성공했으므로)
@ -86,7 +86,7 @@ class MailSentHistoryService {
try { try {
// 디렉토리가 없으면 빈 배열 반환 // 디렉토리가 없으면 빈 배열 반환
if (!fs.existsSync(SENT_MAIL_DIR)) { if (!fs.existsSync(SENT_MAIL_DIR)) {
console.warn("메일 발송 이력 디렉토리가 없습니다:", SENT_MAIL_DIR); // console.warn("메일 발송 이력 디렉토리가 없습니다:", SENT_MAIL_DIR);
return { return {
items: [], items: [],
total: 0, total: 0,
@ -124,6 +124,13 @@ class MailSentHistoryService {
// 필터링 // 필터링
let filtered = allHistory; let filtered = allHistory;
// 삭제된 메일 필터
if (query.onlyDeleted) {
filtered = filtered.filter((h) => h.deletedAt);
} else if (!query.includeDeleted) {
filtered = filtered.filter((h) => !h.deletedAt);
}
// 상태 필터 // 상태 필터
if (status !== "all") { if (status !== "all") {
filtered = filtered.filter((h) => h.status === status); filtered = filtered.filter((h) => h.status === status);
@ -209,9 +216,151 @@ class MailSentHistoryService {
} }
/** /**
* * (Draft)
*/
async saveDraft(
data: Partial<SentMailHistory> & { accountId: string }
): Promise<SentMailHistory> {
// console.log("📥 백엔드에서 받은 임시 저장 데이터:", data);
const now = new Date().toISOString();
const draft: SentMailHistory = {
id: data.id || uuidv4(),
accountId: data.accountId,
accountName: data.accountName || "",
accountEmail: data.accountEmail || "",
to: data.to || [],
cc: data.cc,
bcc: data.bcc,
subject: data.subject || "",
htmlContent: data.htmlContent || "",
templateId: data.templateId,
templateName: data.templateName,
attachments: data.attachments,
sentAt: data.sentAt || now,
status: "draft",
isDraft: true,
updatedAt: now,
};
// console.log("💾 저장할 draft 객체:", draft);
try {
if (!fs.existsSync(SENT_MAIL_DIR)) {
fs.mkdirSync(SENT_MAIL_DIR, { recursive: true, mode: 0o755 });
}
const filePath = path.join(SENT_MAIL_DIR, `${draft.id}.json`);
fs.writeFileSync(filePath, JSON.stringify(draft, null, 2), {
encoding: "utf-8",
mode: 0o644,
});
// console.log("💾 임시 저장:", draft.id);
} catch (error) {
console.error("임시 저장 실패:", error);
throw error;
}
return draft;
}
/**
*
*/
async updateDraft(
id: string,
data: Partial<SentMailHistory>
): Promise<SentMailHistory | null> {
const existing = await this.getSentMailById(id);
if (!existing) {
return null;
}
const updated: SentMailHistory = {
...existing,
...data,
id: existing.id,
updatedAt: new Date().toISOString(),
};
try {
const filePath = path.join(SENT_MAIL_DIR, `${id}.json`);
fs.writeFileSync(filePath, JSON.stringify(updated, null, 2), {
encoding: "utf-8",
mode: 0o644,
});
// console.log("✏️ 임시 저장 업데이트:", id);
return updated;
} catch (error) {
console.error("임시 저장 업데이트 실패:", error);
return null;
}
}
/**
* (Soft Delete)
*/ */
async deleteSentMail(id: string): Promise<boolean> { async deleteSentMail(id: string): Promise<boolean> {
const existing = await this.getSentMailById(id);
if (!existing) {
return false;
}
const updated: SentMailHistory = {
...existing,
deletedAt: new Date().toISOString(),
};
try {
const filePath = path.join(SENT_MAIL_DIR, `${id}.json`);
fs.writeFileSync(filePath, JSON.stringify(updated, null, 2), {
encoding: "utf-8",
mode: 0o644,
});
// console.log("🗑️ 메일 삭제 (Soft Delete):", id);
return true;
} catch (error) {
console.error("메일 삭제 실패:", error);
return false;
}
}
/**
*
*/
async restoreMail(id: string): Promise<boolean> {
const existing = await this.getSentMailById(id);
if (!existing || !existing.deletedAt) {
return false;
}
const updated: SentMailHistory = {
...existing,
deletedAt: undefined,
};
try {
const filePath = path.join(SENT_MAIL_DIR, `${id}.json`);
fs.writeFileSync(filePath, JSON.stringify(updated, null, 2), {
encoding: "utf-8",
mode: 0o644,
});
// console.log("♻️ 메일 복구:", id);
return true;
} catch (error) {
console.error("메일 복구 실패:", error);
return false;
}
}
/**
* (Hard Delete)
*/
async permanentlyDeleteMail(id: string): Promise<boolean> {
const filePath = path.join(SENT_MAIL_DIR, `${id}.json`); const filePath = path.join(SENT_MAIL_DIR, `${id}.json`);
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
@ -220,14 +369,57 @@ class MailSentHistoryService {
try { try {
fs.unlinkSync(filePath); fs.unlinkSync(filePath);
console.log("🗑️ 발송 이력 삭제:", id); // console.log("🗑️ 메일 영구 삭제:", id);
return true; return true;
} catch (error) { } catch (error) {
console.error("발송 이력 삭제 실패:", error); console.error("메일 영구 삭제 실패:", error);
return false; return false;
} }
} }
/**
* 30
*/
async cleanupOldDeletedMails(): Promise<number> {
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
let deletedCount = 0;
try {
if (!fs.existsSync(SENT_MAIL_DIR)) {
return 0;
}
const files = fs
.readdirSync(SENT_MAIL_DIR)
.filter((f) => f.endsWith(".json"));
for (const file of files) {
try {
const filePath = path.join(SENT_MAIL_DIR, file);
const content = fs.readFileSync(filePath, "utf-8");
const mail: SentMailHistory = JSON.parse(content);
if (mail.deletedAt) {
const deletedDate = new Date(mail.deletedAt);
if (deletedDate < thirtyDaysAgo) {
fs.unlinkSync(filePath);
deletedCount++;
// console.log("🗑️ 30일 지난 메일 자동 삭제:", mail.id);
}
}
} catch (error) {
console.error(`파일 처리 실패: ${file}`, error);
}
}
} catch (error) {
console.error("자동 삭제 실패:", error);
}
return deletedCount;
}
/** /**
* *
*/ */

View File

@ -50,11 +50,25 @@ class MailTemplateFileService {
process.env.NODE_ENV === "production" process.env.NODE_ENV === "production"
? "/app/uploads/mail-templates" ? "/app/uploads/mail-templates"
: path.join(process.cwd(), "uploads", "mail-templates"); : path.join(process.cwd(), "uploads", "mail-templates");
this.ensureDirectoryExists(); // 동기적으로 디렉토리 생성
this.ensureDirectoryExistsSync();
} }
/** /**
* 릿 * 릿 ()
*/
private ensureDirectoryExistsSync() {
try {
const fsSync = require('fs');
fsSync.accessSync(this.templatesDir);
} catch {
const fsSync = require('fs');
fsSync.mkdirSync(this.templatesDir, { recursive: true, mode: 0o755 });
}
}
/**
* 릿 ()
*/ */
private async ensureDirectoryExists() { private async ensureDirectoryExists() {
try { try {
@ -75,8 +89,6 @@ class MailTemplateFileService {
* 릿 * 릿
*/ */
async getAllTemplates(): Promise<MailTemplate[]> { async getAllTemplates(): Promise<MailTemplate[]> {
await this.ensureDirectoryExists();
try { try {
const files = await fs.readdir(this.templatesDir); const files = await fs.readdir(this.templatesDir);
const jsonFiles = files.filter((f) => f.endsWith(".json")); const jsonFiles = files.filter((f) => f.endsWith(".json"));
@ -97,6 +109,7 @@ class MailTemplateFileService {
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
); );
} catch (error) { } catch (error) {
// 디렉토리가 없거나 읽기 실패 시 빈 배열 반환
return []; return [];
} }
} }
@ -160,7 +173,7 @@ class MailTemplateFileService {
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
// console.log(`📝 템플릿 저장 시도: ${id}, 크기: ${JSON.stringify(updated).length} bytes`); // // console.log(`📝 템플릿 저장 시도: ${id}, 크기: ${JSON.stringify(updated).length} bytes`);
await fs.writeFile( await fs.writeFile(
this.getTemplatePath(id), this.getTemplatePath(id),
@ -168,7 +181,7 @@ class MailTemplateFileService {
"utf-8" "utf-8"
); );
// console.log(`✅ 템플릿 저장 성공: ${id}`); // // console.log(`✅ 템플릿 저장 성공: ${id}`);
return updated; return updated;
} catch (error) { } catch (error) {
// console.error(`❌ 템플릿 저장 실패: ${id}`, error); // console.error(`❌ 템플릿 저장 실패: ${id}`, error);

View File

@ -24,13 +24,18 @@ export interface SentMailHistory {
// 발송 정보 // 발송 정보
sentAt: string; // 발송 시간 (ISO 8601) sentAt: string; // 발송 시간 (ISO 8601)
status: 'success' | 'failed'; // 발송 상태 status: 'success' | 'failed' | 'draft'; // 발송 상태 (draft 추가)
messageId?: string; // SMTP 메시지 ID (성공 시) messageId?: string; // SMTP 메시지 ID (성공 시)
errorMessage?: string; // 오류 메시지 (실패 시) errorMessage?: string; // 오류 메시지 (실패 시)
// 발송 결과 // 발송 결과
accepted?: string[]; // 수락된 이메일 주소 accepted?: string[]; // 수락된 이메일 주소
rejected?: string[]; // 거부된 이메일 주소 rejected?: string[]; // 거부된 이메일 주소
// 임시 저장 및 삭제
isDraft?: boolean; // 임시 저장 여부
deletedAt?: string; // 삭제 시간 (ISO 8601)
updatedAt?: string; // 수정 시간 (ISO 8601)
} }
export interface AttachmentInfo { export interface AttachmentInfo {
@ -45,12 +50,14 @@ export interface SentMailListQuery {
page?: number; // 페이지 번호 (1부터 시작) page?: number; // 페이지 번호 (1부터 시작)
limit?: number; // 페이지당 항목 수 limit?: number; // 페이지당 항목 수
searchTerm?: string; // 검색어 (제목, 받는사람) searchTerm?: string; // 검색어 (제목, 받는사람)
status?: 'success' | 'failed' | 'all'; // 필터: 상태 status?: 'success' | 'failed' | 'draft' | 'all'; // 필터: 상태 (draft 추가)
accountId?: string; // 필터: 발송 계정 accountId?: string; // 필터: 발송 계정
startDate?: string; // 필터: 시작 날짜 (ISO 8601) startDate?: string; // 필터: 시작 날짜 (ISO 8601)
endDate?: string; // 필터: 종료 날짜 (ISO 8601) endDate?: string; // 필터: 종료 날짜 (ISO 8601)
sortBy?: 'sentAt' | 'subject'; // 정렬 기준 sortBy?: 'sentAt' | 'subject' | 'updatedAt'; // 정렬 기준 (updatedAt 추가)
sortOrder?: 'asc' | 'desc'; // 정렬 순서 sortOrder?: 'asc' | 'desc'; // 정렬 순서
includeDeleted?: boolean; // 삭제된 메일 포함 여부
onlyDeleted?: boolean; // 삭제된 메일만 조회
} }
export interface SentMailListResponse { export interface SentMailListResponse {

View File

@ -38,11 +38,11 @@ export default function MailAccountsPage() {
if (Array.isArray(data)) { if (Array.isArray(data)) {
setAccounts(data); setAccounts(data);
} else { } else {
console.error('API 응답이 배열이 아닙니다:', data); // console.error('API 응답이 배열이 아닙니다:', data);
setAccounts([]); setAccounts([]);
} }
} catch (error) { } catch (error) {
console.error('계정 로드 실패:', error); // console.error('계정 로드 실패:', error);
setAccounts([]); // 에러 시 빈 배열로 설정 setAccounts([]); // 에러 시 빈 배열로 설정
// alert('계정 목록을 불러오는데 실패했습니다.'); // alert('계정 목록을 불러오는데 실패했습니다.');
} finally { } finally {
@ -93,7 +93,7 @@ export default function MailAccountsPage() {
await loadAccounts(); await loadAccounts();
alert('계정이 삭제되었습니다.'); alert('계정이 삭제되었습니다.');
} catch (error) { } catch (error) {
console.error('계정 삭제 실패:', error); // console.error('계정 삭제 실패:', error);
alert('계정 삭제에 실패했습니다.'); alert('계정 삭제에 실패했습니다.');
} }
}; };
@ -104,7 +104,7 @@ export default function MailAccountsPage() {
await updateMailAccount(account.id, { status: newStatus }); await updateMailAccount(account.id, { status: newStatus });
await loadAccounts(); await loadAccounts();
} catch (error) { } catch (error) {
console.error('상태 변경 실패:', error); // console.error('상태 변경 실패:', error);
alert('상태 변경에 실패했습니다.'); alert('상태 변경에 실패했습니다.');
} }
}; };
@ -120,7 +120,7 @@ export default function MailAccountsPage() {
alert(`❌ SMTP 연결 실패\n\n${result.message || '연결에 실패했습니다.'}`); alert(`❌ SMTP 연결 실패\n\n${result.message || '연결에 실패했습니다.'}`);
} }
} catch (error: any) { } catch (error: any) {
console.error('연결 테스트 실패:', error); // console.error('연결 테스트 실패:', error);
alert(`❌ SMTP 연결 테스트 실패\n\n${error.message || '알 수 없는 오류가 발생했습니다.'}`); alert(`❌ SMTP 연결 테스트 실패\n\n${error.message || '알 수 없는 오류가 발생했습니다.'}`);
} finally { } finally {
setLoading(false); setLoading(false);

View File

@ -0,0 +1,524 @@
"use client";
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Upload,
Send,
FileText,
Users,
AlertCircle,
CheckCircle2,
Loader2,
Download,
X,
} from "lucide-react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useToast } from "@/hooks/use-toast";
import {
MailAccount,
MailTemplate,
getMailAccounts,
getMailTemplates,
sendBulkMail,
} from "@/lib/api/mail";
interface RecipientData {
email: string;
variables: Record<string, string>;
}
export default function BulkSendPage() {
const router = useRouter();
const { toast } = useToast();
const [accounts, setAccounts] = useState<MailAccount[]>([]);
const [templates, setTemplates] = useState<MailTemplate[]>([]);
const [selectedAccountId, setSelectedAccountId] = useState<string>("");
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
const [useTemplate, setUseTemplate] = useState<boolean>(true); // 템플릿 사용 여부
const [customHtml, setCustomHtml] = useState<string>(""); // 직접 작성한 HTML
const [subject, setSubject] = useState<string>("");
const [recipients, setRecipients] = useState<RecipientData[]>([]);
const [csvFile, setCsvFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [sending, setSending] = useState(false);
const [sendProgress, setSendProgress] = useState({ sent: 0, total: 0 });
useEffect(() => {
loadAccounts();
loadTemplates();
}, []);
const loadAccounts = async () => {
try {
const data = await getMailAccounts();
setAccounts(data.filter((acc) => acc.status === 'active'));
} catch (error: unknown) {
const err = error as Error;
toast({
title: "계정 로드 실패",
description: err.message,
variant: "destructive",
});
}
};
const loadTemplates = async () => {
try {
const data = await getMailTemplates();
setTemplates(data);
} catch (error: unknown) {
const err = error as Error;
toast({
title: "템플릿 로드 실패",
description: err.message,
variant: "destructive",
});
}
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.name.endsWith(".csv")) {
toast({
title: "파일 형식 오류",
description: "CSV 파일만 업로드 가능합니다.",
variant: "destructive",
});
return;
}
setCsvFile(file);
setLoading(true);
try {
const text = await file.text();
const lines = text.split("\n").filter((line) => line.trim());
if (lines.length < 2) {
throw new Error("CSV 파일에 데이터가 없습니다.");
}
// 첫 줄은 헤더
const headers = lines[0].split(",").map((h) => h.trim());
if (!headers.includes("email")) {
throw new Error("CSV 파일에 'email' 컬럼이 필요합니다.");
}
const emailIndex = headers.indexOf("email");
const variableHeaders = headers.filter((h) => h !== "email");
const parsedRecipients: RecipientData[] = lines.slice(1).map((line) => {
const values = line.split(",").map((v) => v.trim());
const email = values[emailIndex];
const variables: Record<string, string> = {};
variableHeaders.forEach((header, index) => {
const valueIndex = headers.indexOf(header);
variables[header] = values[valueIndex] || "";
});
return { email, variables };
});
setRecipients(parsedRecipients);
toast({
title: "파일 업로드 성공",
description: `${parsedRecipients.length}명의 수신자를 불러왔습니다.`,
});
} catch (error: unknown) {
const err = error as Error;
toast({
title: "파일 파싱 실패",
description: err.message,
variant: "destructive",
});
setCsvFile(null);
setRecipients([]);
} finally {
setLoading(false);
}
};
const handleSend = async () => {
if (!selectedAccountId) {
toast({
title: "계정 선택 필요",
description: "발송할 메일 계정을 선택해주세요.",
variant: "destructive",
});
return;
}
// 템플릿 또는 직접 작성 중 하나는 있어야 함
if (useTemplate && !selectedTemplateId) {
toast({
title: "템플릿 선택 필요",
description: "사용할 템플릿을 선택해주세요.",
variant: "destructive",
});
return;
}
if (!useTemplate && !customHtml.trim()) {
toast({
title: "내용 입력 필요",
description: "메일 내용을 입력해주세요.",
variant: "destructive",
});
return;
}
if (!subject.trim()) {
toast({
title: "제목 입력 필요",
description: "메일 제목을 입력해주세요.",
variant: "destructive",
});
return;
}
if (recipients.length === 0) {
toast({
title: "수신자 없음",
description: "CSV 파일을 업로드해주세요.",
variant: "destructive",
});
return;
}
setSending(true);
setSendProgress({ sent: 0, total: recipients.length });
try {
await sendBulkMail({
accountId: selectedAccountId,
templateId: useTemplate ? selectedTemplateId : undefined,
customHtml: !useTemplate ? customHtml : undefined,
subject,
recipients,
onProgress: (sent, total) => {
setSendProgress({ sent, total });
},
});
toast({
title: "대량 발송 완료",
description: `${recipients.length}명에게 메일을 발송했습니다.`,
});
// 초기화
setSelectedAccountId("");
setSelectedTemplateId("");
setSubject("");
setRecipients([]);
setCsvFile(null);
} catch (error: unknown) {
const err = error as Error;
toast({
title: "발송 실패",
description: err.message,
variant: "destructive",
});
} finally {
setSending(false);
}
};
const downloadSampleCsv = () => {
const sample = `email,name,company
example1@example.com,,ABC회사
example2@example.com,,XYZ회사`;
const blob = new Blob([sample], { type: "text/csv;charset=utf-8;" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = "sample.csv";
link.click();
};
return (
<div className="min-h-screen bg-background">
<div className="mx-auto w-full space-y-6 px-6 py-8">
{/* 헤더 */}
<div className="flex items-center justify-between rounded-lg border bg-card p-8">
<div className="flex items-center gap-4">
<div className="rounded-lg bg-primary/10 p-4">
<Users className="h-8 w-8 text-primary" />
</div>
<div>
<h1 className="mb-1 text-3xl font-bold text-foreground"> </h1>
<p className="text-muted-foreground">CSV </p>
</div>
</div>
<Link href="/admin/mail/dashboard">
<Button variant="outline" size="lg">
</Button>
</Link>
</div>
<div className="grid gap-6 lg:grid-cols-2">
{/* 왼쪽: 설정 */}
<div className="space-y-6">
{/* 계정 선택 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="account"> </Label>
<Select value={selectedAccountId} onValueChange={setSelectedAccountId}>
<SelectTrigger id="account">
<SelectValue placeholder="계정 선택" />
</SelectTrigger>
<SelectContent>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
{account.name} ({account.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="mode"> </Label>
<Select value={useTemplate ? "template" : "custom"} onValueChange={(v) => setUseTemplate(v === "template")}>
<SelectTrigger id="mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="template">릿 </SelectItem>
<SelectItem value="custom"> </SelectItem>
</SelectContent>
</Select>
</div>
{useTemplate ? (
<div>
<Label htmlFor="template">릿</Label>
<Select value={selectedTemplateId} onValueChange={setSelectedTemplateId}>
<SelectTrigger id="template">
<SelectValue placeholder="템플릿 선택" />
</SelectTrigger>
<SelectContent>
{templates.map((template) => (
<SelectItem key={template.id} value={template.id}>
{template.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div>
<Label htmlFor="customHtml"> </Label>
<Textarea
id="customHtml"
value={customHtml}
onChange={(e) => setCustomHtml(e.target.value)}
placeholder="메일 내용을 작성하세요..."
rows={10}
className="text-sm"
/>
{/* <p className="mt-1 text-xs text-muted-foreground">
HTML
</p> */}
</div>
)}
<div>
<Label htmlFor="subject"></Label>
<Input
id="subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="메일 제목을 입력하세요"
/>
</div>
</CardContent>
</Card>
{/* CSV 업로드 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label htmlFor="csv">CSV </Label>
<div className="mt-2 flex gap-2">
<Input
id="csv"
type="file"
accept=".csv"
onChange={handleFileUpload}
disabled={loading}
/>
<Button
variant="outline"
size="icon"
onClick={downloadSampleCsv}
title="샘플 다운로드"
>
<Download className="h-4 w-4" />
</Button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
(email, name, company ) .
</p>
</div>
{csvFile && (
<div className="flex items-center justify-between rounded-md border bg-muted p-3">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-sm">{csvFile.name}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setCsvFile(null);
setRecipients([]);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
)}
{recipients.length > 0 && (
<div className="rounded-md border bg-muted p-4">
<div className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-4 w-4 text-green-600" />
<span className="font-medium">{recipients.length} </span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
: {Object.keys(recipients[0]?.variables || {}).join(", ")}
</p>
</div>
)}
</CardContent>
</Card>
</div>
{/* 오른쪽: 미리보기 & 발송 */}
<div className="space-y-6">
{/* 발송 버튼 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{sending && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span> ...</span>
<span className="font-medium">
{sendProgress.sent} / {sendProgress.total}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-primary transition-all duration-300"
style={{
width: `${(sendProgress.sent / sendProgress.total) * 100}%`,
}}
/>
</div>
</div>
)}
<Button
onClick={handleSend}
disabled={sending || recipients.length === 0}
className="w-full"
size="lg"
>
{sending ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
...
</>
) : (
<>
<Send className="mr-2 h-5 w-5" />
{recipients.length}
</>
)}
</Button>
<div className="rounded-md border bg-muted p-4">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="text-xs text-muted-foreground">
<p className="font-medium"></p>
<ul className="mt-1 list-inside list-disc space-y-1">
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
</div>
</CardContent>
</Card>
{/* 수신자 목록 미리보기 */}
{recipients.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="max-h-96 space-y-2 overflow-y-auto">
{recipients.slice(0, 10).map((recipient, index) => (
<div
key={index}
className="rounded-md border bg-muted p-3 text-sm"
>
<div className="font-medium">{recipient.email}</div>
<div className="mt-1 text-xs text-muted-foreground">
{Object.entries(recipient.variables).map(([key, value]) => (
<span key={key} className="mr-2">
{key}: {value}
</span>
))}
</div>
</div>
))}
{recipients.length > 10 && (
<p className="text-center text-xs text-muted-foreground">
{recipients.length - 10}
</p>
)}
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -13,9 +13,12 @@ import {
TrendingUp, TrendingUp,
Users, Users,
Calendar, Calendar,
ArrowRight ArrowRight,
Trash2,
Edit
} from "lucide-react"; } from "lucide-react";
import { getMailAccounts, getMailTemplates, getMailStatistics, getTodayReceivedCount } from "@/lib/api/mail"; import { getMailAccounts, getMailTemplates, getMailStatistics, getTodayReceivedCount } from "@/lib/api/mail";
import MailNotifications from "@/components/mail/MailNotifications";
interface DashboardStats { interface DashboardStats {
totalAccounts: number; totalAccounts: number;
@ -42,14 +45,34 @@ export default function MailDashboardPage() {
try { try {
const accounts = await getMailAccounts(); const accounts = await getMailAccounts();
const templates = await getMailTemplates(); const templates = await getMailTemplates();
const mailStats = await getMailStatistics();
// 메일 통계 조회 (실패 시 기본값 사용)
let mailStats = {
todayCount: 0,
thisMonthCount: 0,
successRate: 0,
};
try {
const stats = await getMailStatistics();
if (stats && typeof stats === 'object') {
mailStats = {
todayCount: stats.todayCount || 0,
thisMonthCount: stats.thisMonthCount || 0,
successRate: stats.successRate || 0,
};
}
} catch (error) {
// console.error('메일 통계 조회 실패:', error);
// 기본값 사용
}
// 오늘 수신 메일 수 조회 (IMAP 실시간 조회) // 오늘 수신 메일 수 조회 (IMAP 실시간 조회)
let receivedTodayCount = 0; let receivedTodayCount = 0;
try { try {
receivedTodayCount = await getTodayReceivedCount(); receivedTodayCount = await getTodayReceivedCount();
} catch (error) { } catch (error) {
console.error('수신 메일 수 조회 실패:', error); // console.error('수신 메일 수 조회 실패:', error);
// 실패 시 0으로 표시 // 실패 시 0으로 표시
} }
@ -133,6 +156,13 @@ export default function MailDashboardPage() {
icon: Send, icon: Send,
color: "orange", color: "orange",
}, },
{
title: "대량 발송",
description: "CSV로 대량 발송",
href: "/admin/mail/bulk-send",
icon: Users,
color: "teal",
},
{ {
title: "보낸메일함", title: "보낸메일함",
description: "발송 이력 확인", description: "발송 이력 확인",
@ -147,11 +177,25 @@ export default function MailDashboardPage() {
icon: Inbox, icon: Inbox,
color: "purple", color: "purple",
}, },
{
title: "임시 저장",
description: "작성 중인 메일",
href: "/admin/mail/drafts",
icon: Edit,
color: "amber",
},
{
title: "휴지통",
description: "삭제된 메일",
href: "/admin/mail/trash",
icon: Trash2,
color: "red",
},
]; ];
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<div className="w-full max-w-7xl mx-auto px-6 py-8 space-y-6"> <div className="w-full px-3 py-3 space-y-3">
{/* 페이지 제목 */} {/* 페이지 제목 */}
<div className="flex items-center justify-between bg-card rounded-lg border p-8"> <div className="flex items-center justify-between bg-card rounded-lg border p-8">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -163,19 +207,22 @@ export default function MailDashboardPage() {
<p className="text-muted-foreground"> </p> <p className="text-muted-foreground"> </p>
</div> </div>
</div> </div>
<Button <div className="flex gap-3">
variant="outline" <MailNotifications />
size="lg" <Button
onClick={loadStats} variant="outline"
disabled={loading} size="lg"
> onClick={loadStats}
<RefreshCw className={`w-5 h-5 mr-2 ${loading ? 'animate-spin' : ''}`} /> disabled={loading}
>
</Button> <RefreshCw className={`w-5 h-5 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div> </div>
{/* 통계 카드 */} {/* 통계 카드 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{statCards.map((stat, index) => ( {statCards.map((stat, index) => (
<Link key={index} href={stat.href}> <Link key={index} href={stat.href}>
<Card className="hover:shadow-md transition-all hover:scale-105 cursor-pointer"> <Card className="hover:shadow-md transition-all hover:scale-105 cursor-pointer">
@ -207,7 +254,7 @@ export default function MailDashboardPage() {
</div> </div>
{/* 이번 달 통계 */} {/* 이번 달 통계 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<Card> <Card>
<CardHeader className="border-b"> <CardHeader className="border-b">
<CardTitle className="text-lg flex items-center"> <CardTitle className="text-lg flex items-center">

View File

@ -0,0 +1,201 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { getSentMailList, updateDraft, deleteSentMail, bulkDeleteMails, type SentMailHistory } from "@/lib/api/mail";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Edit, Trash2, Loader2, Mail } from "lucide-react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
export default function DraftsPage() {
const router = useRouter();
const [drafts, setDrafts] = useState<SentMailHistory[]>([]);
const [loading, setLoading] = useState(true);
const [deleting, setDeleting] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [bulkDeleting, setBulkDeleting] = useState(false);
useEffect(() => {
loadDrafts();
}, []);
const loadDrafts = async () => {
try {
setLoading(true);
const response = await getSentMailList({
status: "draft",
sortBy: "updatedAt",
sortOrder: "desc",
});
// console.log('📋 임시 저장 목록 조회:', response);
// console.log('📋 임시 저장 개수:', response.items.length);
setDrafts(response.items);
} catch (error) {
// console.error("❌ 임시 저장 메일 로드 실패:", error);
} finally {
setLoading(false);
}
};
const handleEdit = (draft: SentMailHistory) => {
// 임시 저장 메일을 메일 발송 페이지로 전달
const params = new URLSearchParams({
draftId: draft.id,
to: draft.to.join(","),
cc: draft.cc?.join(",") || "",
bcc: draft.bcc?.join(",") || "",
subject: draft.subject,
content: draft.htmlContent,
accountId: draft.accountId,
});
router.push(`/admin/mail/send?${params.toString()}`);
};
const handleDelete = async (id: string) => {
if (!confirm("이 임시 저장 메일을 삭제하시겠습니까?")) return;
try {
setDeleting(id);
await deleteSentMail(id);
setDrafts(drafts.filter((d) => d.id !== id));
setSelectedIds(selectedIds.filter((selectedId) => selectedId !== id));
} catch (error) {
// console.error("임시 저장 메일 삭제 실패:", error);
alert("삭제에 실패했습니다.");
} finally {
setDeleting(null);
}
};
const handleBulkDelete = async () => {
if (selectedIds.length === 0) {
alert("삭제할 메일을 선택해주세요.");
return;
}
if (!confirm(`선택한 ${selectedIds.length}개의 임시 저장 메일을 삭제하시겠습니까?`)) return;
try {
setBulkDeleting(true);
const result = await bulkDeleteMails(selectedIds);
setDrafts(drafts.filter((d) => !selectedIds.includes(d.id)));
setSelectedIds([]);
alert(result.message);
} catch (error) {
// console.error("일괄 삭제 실패:", error);
alert("일괄 삭제에 실패했습니다.");
} finally {
setBulkDeleting(false);
}
};
const handleSelectAll = () => {
if (selectedIds.length === drafts.length) {
setSelectedIds([]);
} else {
setSelectedIds(drafts.map((d) => d.id));
}
};
const handleSelectOne = (id: string) => {
if (selectedIds.includes(id)) {
setSelectedIds(selectedIds.filter((selectedId) => selectedId !== id));
} else {
setSelectedIds([...selectedIds, id]);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
);
}
return (
<div className="p-3 space-y-3">
<div>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"> </p>
</div>
{drafts.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Mail className="w-12 h-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground"> </p>
</CardContent>
</Card>
) : (
<div className="grid gap-3">
{drafts.map((draft) => (
<Card key={draft.id} className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<CardTitle className="text-lg truncate">
{draft.subject || "(제목 없음)"}
</CardTitle>
<CardDescription className="mt-1">
: {draft.to.join(", ") || "(없음)"}
</CardDescription>
</div>
<div className="flex items-center gap-2 ml-4">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(draft)}
className="h-8"
>
<Edit className="w-4 h-4 mr-1" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(draft.id)}
disabled={deleting === draft.id}
className="h-8"
>
{deleting === draft.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
<Trash2 className="w-4 h-4 mr-1" />
</>
)}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>: {draft.accountName || draft.accountEmail}</span>
<span>
{draft.updatedAt
? format(new Date(draft.updatedAt), "yyyy-MM-dd HH:mm", { locale: ko })
: format(new Date(draft.sentAt), "yyyy-MM-dd HH:mm", { locale: ko })}
</span>
</div>
{draft.htmlContent && (
<div
className="mt-2 text-sm text-muted-foreground line-clamp-2"
dangerouslySetInnerHTML={{
__html: draft.htmlContent.replace(/<[^>]*>/g, "").substring(0, 100),
}}
/>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@ -16,21 +16,30 @@ import {
SortAsc, SortAsc,
SortDesc, SortDesc,
ChevronRight, ChevronRight,
Reply,
Forward,
Trash2,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { import {
MailAccount, MailAccount,
ReceivedMail, ReceivedMail,
MailDetail,
getMailAccounts, getMailAccounts,
getReceivedMails, getReceivedMails,
testImapConnection, testImapConnection,
getMailDetail,
markMailAsRead,
downloadMailAttachment,
} from "@/lib/api/mail"; } from "@/lib/api/mail";
import MailDetailModal from "@/components/mail/MailDetailModal"; import { apiClient } from "@/lib/api/client";
import DOMPurify from "isomorphic-dompurify";
export default function MailReceivePage() { export default function MailReceivePage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const [accounts, setAccounts] = useState<MailAccount[]>([]); const [accounts, setAccounts] = useState<MailAccount[]>([]);
const [selectedAccountId, setSelectedAccountId] = useState<string>(""); const [selectedAccountId, setSelectedAccountId] = useState<string>("");
const [mails, setMails] = useState<ReceivedMail[]>([]); const [mails, setMails] = useState<ReceivedMail[]>([]);
@ -41,15 +50,23 @@ export default function MailReceivePage() {
message: string; message: string;
} | null>(null); } | null>(null);
// 메일 상세 모달 상태 // 메일 상세 상태 (모달 대신 패널)
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
const [selectedMailId, setSelectedMailId] = useState<string>(""); const [selectedMailId, setSelectedMailId] = useState<string>("");
const [selectedMailDetail, setSelectedMailDetail] = useState<MailDetail | null>(null);
const [loadingDetail, setLoadingDetail] = useState(false);
const [deleting, setDeleting] = useState(false);
// 검색 및 필터 상태 // 검색 및 필터 상태
const [searchTerm, setSearchTerm] = useState<string>(""); const [searchTerm, setSearchTerm] = useState<string>("");
const [filterStatus, setFilterStatus] = useState<string>("all"); // all, unread, read, attachment const [filterStatus, setFilterStatus] = useState<string>("all"); // all, unread, read, attachment
const [sortBy, setSortBy] = useState<string>("date-desc"); // date-desc, date-asc, from-asc, from-desc const [sortBy, setSortBy] = useState<string>("date-desc"); // date-desc, date-asc, from-asc, from-desc
// 페이지네이션
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage] = useState(10);
const [totalPages, setTotalPages] = useState(1);
const [allMails, setAllMails] = useState<ReceivedMail[]>([]); // 전체 메일 저장
// 계정 목록 로드 // 계정 목록 로드
useEffect(() => { useEffect(() => {
loadAccounts(); loadAccounts();
@ -58,10 +75,42 @@ export default function MailReceivePage() {
// 계정 선택 시 메일 로드 // 계정 선택 시 메일 로드
useEffect(() => { useEffect(() => {
if (selectedAccountId) { if (selectedAccountId) {
setCurrentPage(1); // 계정 변경 시 첫 페이지로
loadMails(); loadMails();
} }
}, [selectedAccountId]); }, [selectedAccountId]);
// 페이지 변경 시 페이지네이션 재적용
useEffect(() => {
if (allMails.length > 0) {
applyPagination(allMails);
}
}, [currentPage]);
// URL 파라미터에서 mailId 읽기 및 자동 선택
useEffect(() => {
const mailId = searchParams.get('mailId');
const accountId = searchParams.get('accountId');
if (mailId && accountId) {
// console.log('📧 URL에서 메일 ID 감지:', mailId, accountId);
setSelectedAccountId(accountId);
setSelectedMailId(mailId);
// 메일 상세 로드는 handleMailClick에서 처리됨
}
}, [searchParams]);
// 메일 목록 로드 후 URL에서 지정된 메일 자동 선택
useEffect(() => {
if (selectedMailId && mails.length > 0 && !selectedMailDetail) {
const mail = mails.find(m => m.id === selectedMailId);
if (mail) {
// console.log('🎯 URL에서 지정된 메일 자동 선택:', selectedMailId);
handleMailClick(mail);
}
}
}, [mails, selectedMailId, selectedMailDetail]); // selectedMailDetail 추가로 무한 루프 방지
// 자동 새로고침 (30초마다) // 자동 새로고침 (30초마다)
useEffect(() => { useEffect(() => {
if (!selectedAccountId) return; if (!selectedAccountId) return;
@ -84,7 +133,7 @@ export default function MailReceivePage() {
} }
} }
} catch (error) { } catch (error) {
console.error("계정 로드 실패:", error); // console.error("계정 로드 실패:", error);
} }
}; };
@ -94,21 +143,47 @@ export default function MailReceivePage() {
setLoading(true); setLoading(true);
setTestResult(null); setTestResult(null);
try { try {
const data = await getReceivedMails(selectedAccountId, 50); const data = await getReceivedMails(selectedAccountId, 200); // 더 많이 가져오기
setMails(data);
// 현재 로컬에서 읽음 처리한 메일들의 상태를 유지
const processedMails = data.map(mail => ({
...mail,
isRead: mail.isRead
}));
setAllMails(processedMails); // 전체 메일 저장
// 페이지네이션 적용
applyPagination(processedMails);
// 알림 갱신 이벤트 발생 (새 메일이 있을 수 있음)
window.dispatchEvent(new CustomEvent('mail-received'));
} catch (error) { } catch (error) {
console.error("메일 로드 실패:", error); // console.error("메일 로드 실패:", error);
alert( alert(
error instanceof Error error instanceof Error
? error.message ? error.message
: "메일을 불러오는데 실패했습니다." : "메일을 불러오는데 실패했습니다."
); );
setMails([]); setMails([]);
setAllMails([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const applyPagination = (mailList: ReceivedMail[]) => {
const totalItems = mailList.length;
const totalPagesCalc = Math.ceil(totalItems / itemsPerPage);
setTotalPages(totalPagesCalc);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedMails = mailList.slice(startIndex, endIndex);
setMails(paginatedMails);
};
const handleTestConnection = async () => { const handleTestConnection = async () => {
if (!selectedAccountId) return; if (!selectedAccountId) return;
@ -153,14 +228,94 @@ export default function MailReceivePage() {
} }
}; };
const handleMailClick = (mail: ReceivedMail) => { const handleMailClick = async (mail: ReceivedMail) => {
setSelectedMailId(mail.id); setSelectedMailId(mail.id);
setIsDetailModalOpen(true); setLoadingDetail(true);
// 즉시 로컬 상태 업데이트 (UI 반응성 향상)
// console.log('📧 메일 클릭:', mail.id, '현재 읽음 상태:', mail.isRead);
setMails((prevMails) =>
prevMails.map((m) =>
m.id === mail.id ? { ...m, isRead: true } : m
)
);
// 메일 상세 정보 로드
try {
// mail.id에서 accountId와 seqno 추출: "account-{timestamp}-{seqno}" 형식
const mailIdParts = mail.id.split('-');
const accountId = `${mailIdParts[0]}-${mailIdParts[1]}`; // "account-1759310844272"
const seqno = parseInt(mailIdParts[2], 10); // 13
// console.log('🔍 추출된 accountId:', accountId, 'seqno:', seqno, '원본 mailId:', mail.id);
const detail = await getMailDetail(accountId, seqno);
setSelectedMailDetail(detail);
// 읽음 처리
if (!mail.isRead) {
await markMailAsRead(accountId, seqno);
// console.log('✅ 읽음 처리 완료 - seqno:', seqno);
// 서버 상태 동기화 (백그라운드) - IMAP 서버 반영 대기
setTimeout(() => {
if (selectedAccountId) {
// console.log('🔄 서버 상태 동기화 시작');
loadMails();
}
}, 2000); // 2초로 증가
}
} catch (error) {
// console.error('메일 상세 로드 실패:', error);
} finally {
setLoadingDetail(false);
}
}; };
const handleMailRead = () => { const handleDeleteMail = async () => {
// 메일을 읽었으므로 목록 새로고침 if (!selectedMailId || !confirm("이 메일을 IMAP 서버에서 삭제하시겠습니까?\n(Gmail/Naver 휴지통으로 이동됩니다)\n\n⚠ IMAP 연결에 시간이 걸릴 수 있습니다.")) return;
loadMails();
try {
setDeleting(true);
// mail.id에서 accountId와 seqno 추출: "account-{timestamp}-{seqno}" 형식
const mailIdParts = selectedMailId.split('-');
const accountId = `${mailIdParts[0]}-${mailIdParts[1]}`; // "account-1759310844272"
const seqno = parseInt(mailIdParts[2], 10); // 10
// console.log(`🗑️ 메일 삭제 시도: accountId=${accountId}, seqno=${seqno}`);
// IMAP 서버에서 메일 삭제 (타임아웃 40초)
const response = await apiClient.delete(`/mail/receive/${accountId}/${seqno}`, {
timeout: 40000, // 40초 타임아웃
});
if (response.data.success) {
// 메일 목록에서 제거
setMails(mails.filter((m) => m.id !== selectedMailId));
// 상세 패널 닫기
setSelectedMailId("");
setSelectedMailDetail(null);
alert("메일이 삭제되었습니다.");
// console.log("✅ 메일 삭제 완료");
}
} catch (error: any) {
// console.error("메일 삭제 실패:", error);
let errorMessage = "메일 삭제에 실패했습니다.";
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
errorMessage = "IMAP 서버 연결 시간 초과\n네트워크 상태를 확인하거나 나중에 다시 시도해주세요.";
} else if (error.response?.data?.message) {
errorMessage = error.response.data.message;
}
alert(errorMessage);
} finally {
setDeleting(false);
}
}; };
// 필터링 및 정렬된 메일 목록 // 필터링 및 정렬된 메일 목록
@ -365,106 +520,384 @@ export default function MailReceivePage() {
</Card> </Card>
)} )}
{/* 메일 목록 */} {/* 네이버 메일 스타일 3-column 레이아웃 */}
{loading ? ( <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className=""> {/* 왼쪽: 메일 목록 */}
<CardContent className="flex justify-center items-center py-16"> <div className="lg:col-span-1">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" /> {loading ? (
<span className="ml-3 text-muted-foreground"> ...</span> <Card className="">
</CardContent> <CardContent className="flex justify-center items-center py-16">
</Card> <Loader2 className="w-8 h-8 animate-spin text-orange-500" />
) : filteredAndSortedMails.length === 0 ? ( <span className="ml-3 text-muted-foreground"> ...</span>
<Card className="text-center py-16 bg-card "> </CardContent>
<CardContent className="pt-6"> </Card>
<Mail className="w-16 h-16 mx-auto mb-4 text-gray-300" /> ) : filteredAndSortedMails.length === 0 ? (
<p className="text-muted-foreground mb-4"> <Card className="text-center py-16 bg-card ">
{!selectedAccountId <CardContent className="pt-6">
? "메일 계정을 선택하세요" <Mail className="w-16 h-16 mx-auto mb-4 text-gray-300" />
: searchTerm || filterStatus !== "all" <p className="text-muted-foreground mb-4">
? "검색 결과가 없습니다" {!selectedAccountId
: "받은 메일이 없습니다"} ? "메일 계정을 선택하세요"
</p> : searchTerm || filterStatus !== "all"
{selectedAccountId && ( ? "검색 결과가 없습니다"
<Button : "받은 메일이 없습니다"}
onClick={handleTestConnection} </p>
variant="outline" {selectedAccountId && (
disabled={testing} <Button
> onClick={handleTestConnection}
{testing ? ( variant="outline"
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> disabled={testing}
) : ( >
<CheckCircle className="w-4 h-4 mr-2" /> {testing ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<CheckCircle className="w-4 h-4 mr-2" />
)}
IMAP
</Button>
)} )}
IMAP </CardContent>
</Button> </Card>
)} ) : (
</CardContent> <Card className="">
</Card> <CardHeader className="bg-gradient-to-r from-slate-50 to-gray-50 border-b">
) : ( <CardTitle className="flex items-center gap-2">
<Card className=""> <Inbox className="w-5 h-5 text-orange-500" />
<CardHeader className="bg-gradient-to-r from-slate-50 to-gray-50 border-b"> ({filteredAndSortedMails.length}/{mails.length})
<CardTitle className="flex items-center gap-2"> </CardTitle>
<Inbox className="w-5 h-5 text-orange-500" /> </CardHeader>
({filteredAndSortedMails.length}/{mails.length}) <CardContent className="p-0">
</CardTitle> <div className="divide-y max-h-[calc(100vh-300px)] overflow-y-auto">
</CardHeader> {filteredAndSortedMails.map((mail) => (
<CardContent className="p-0"> <div
<div className="divide-y"> key={mail.id}
{filteredAndSortedMails.map((mail) => ( onClick={() => handleMailClick(mail)}
<div className={`p-4 hover:bg-background transition-colors cursor-pointer ${
key={mail.id} !mail.isRead ? "bg-blue-50/30" : ""
onClick={() => handleMailClick(mail)} } ${selectedMailId === mail.id ? "bg-accent border-l-4 border-l-primary" : ""}`}
className={`p-4 hover:bg-background transition-colors cursor-pointer ${ >
!mail.isRead ? "bg-blue-50/30" : "" <div className="flex items-start gap-4">
}`} {/* 읽음 표시 */}
> <div className="flex-shrink-0 w-2 h-2 mt-2">
<div className="flex items-start gap-4"> {!mail.isRead && (
{/* 읽음 표시 */} <div className="w-2 h-2 bg-blue-500 rounded-full"></div>
<div className="flex-shrink-0 w-2 h-2 mt-2">
{!mail.isRead && (
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
)}
</div>
{/* 메일 내용 */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span
className={`text-sm ${
mail.isRead
? "text-muted-foreground"
: "text-foreground font-semibold"
}`}
>
{mail.from}
</span>
<div className="flex items-center gap-2">
{mail.hasAttachments && (
<Paperclip className="w-4 h-4 text-gray-400" />
)} )}
<span className="text-xs text-muted-foreground"> </div>
{formatDate(mail.date)}
</span> {/* 메일 내용 */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span
className={`text-sm ${
mail.isRead
? "text-muted-foreground"
: "text-foreground font-semibold"
}`}
>
{mail.from}
</span>
<div className="flex items-center gap-2">
{mail.hasAttachments && (
<Paperclip className="w-4 h-4 text-gray-400" />
)}
<span className="text-xs text-muted-foreground">
{formatDate(mail.date)}
</span>
</div>
</div>
<h3
className={`text-sm mb-1 truncate ${
mail.isRead ? "text-foreground" : "text-foreground font-medium"
}`}
>
{mail.subject}
</h3>
<p className="text-xs text-muted-foreground line-clamp-2">
{mail.preview}
</p>
</div> </div>
</div> </div>
<h3
className={`text-sm mb-1 truncate ${
mail.isRead ? "text-foreground" : "text-foreground font-medium"
}`}
>
{mail.subject}
</h3>
<p className="text-xs text-muted-foreground line-clamp-2">
{mail.preview}
</p>
</div> </div>
))}
</div>
</CardContent>
{/* 페이지네이션 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 p-4 border-t">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
disabled={currentPage === 1}
>
</Button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (currentPage <= 3) {
pageNum = i + 1;
} else if (currentPage >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = currentPage - 2 + i;
}
return (
<Button
key={pageNum}
variant={currentPage === pageNum ? "default" : "outline"}
size="sm"
onClick={() => setCurrentPage(pageNum)}
className="w-8 h-8 p-0"
>
{pageNum}
</Button>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages}
>
</Button>
</div>
)}
</Card>
)}
</div>
{/* 오른쪽: 메일 상세 패널 */}
<div className="lg:col-span-1">
{selectedMailDetail ? (
<Card className="sticky top-6">
<CardHeader className="border-b">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{selectedMailDetail.subject}</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedMailId("");
setSelectedMailDetail(null);
}}
>
</Button>
</div>
<div className="text-sm text-muted-foreground space-y-1 mt-2">
<div className="flex items-center gap-2">
<span className="font-medium"> :</span>
<span>{selectedMailDetail.from}</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium"> :</span>
<span>{selectedMailDetail.to}</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium">:</span>
<span>{new Date(selectedMailDetail.date).toLocaleString("ko-KR")}</span>
</div> </div>
</div> </div>
))}
</div> {/* 답장/전달/삭제 버튼 */}
</CardContent> <div className="flex gap-2 mt-4">
</Card> <Button
)} variant="outline"
size="sm"
onClick={() => {
// HTML 태그 제거 함수 (강력한 버전)
const stripHtml = (html: string) => {
if (!html) return "";
// 1. DOMPurify로 먼저 정제
const cleanHtml = DOMPurify.sanitize(html, {
ALLOWED_TAGS: [], // 모든 태그 제거
KEEP_CONTENT: true // 내용만 유지
});
// 2. DOM으로 텍스트만 추출
const tmp = document.createElement("DIV");
tmp.innerHTML = cleanHtml;
let text = tmp.textContent || tmp.innerText || "";
// 3. CSS 스타일 제거 (p{...} 같은 패턴)
text = text.replace(/[a-z-]+\{[^}]*\}/gi, '');
// 4. 연속된 공백 정리
text = text.replace(/\s+/g, ' ').trim();
return text;
};
// console.log('📧 답장 데이터:', {
// htmlBody: selectedMailDetail.htmlBody,
// textBody: selectedMailDetail.textBody,
// });
// textBody 우선 사용 (순수 텍스트), 없으면 htmlBody에서 추출
const bodyText = selectedMailDetail.textBody
|| (selectedMailDetail.htmlBody ? stripHtml(selectedMailDetail.htmlBody) : "");
// console.log('📧 변환된 본문:', bodyText);
const replyData = {
originalFrom: selectedMailDetail.from,
originalSubject: selectedMailDetail.subject,
originalDate: selectedMailDetail.date,
originalBody: bodyText,
};
router.push(
`/admin/mail/send?action=reply&data=${encodeURIComponent(JSON.stringify(replyData))}`
);
}}
>
<Reply className="w-4 h-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
// HTML 태그 제거 함수 (강력한 버전)
const stripHtml = (html: string) => {
if (!html) return "";
// 1. DOMPurify로 먼저 정제
const cleanHtml = DOMPurify.sanitize(html, {
ALLOWED_TAGS: [], // 모든 태그 제거
KEEP_CONTENT: true // 내용만 유지
});
// 2. DOM으로 텍스트만 추출
const tmp = document.createElement("DIV");
tmp.innerHTML = cleanHtml;
let text = tmp.textContent || tmp.innerText || "";
// 3. CSS 스타일 제거 (p{...} 같은 패턴)
text = text.replace(/[a-z-]+\{[^}]*\}/gi, '');
// 4. 연속된 공백 정리
text = text.replace(/\s+/g, ' ').trim();
return text;
};
// console.log('📧 전달 데이터:', {
// htmlBody: selectedMailDetail.htmlBody,
// textBody: selectedMailDetail.textBody,
// });
// textBody 우선 사용 (순수 텍스트), 없으면 htmlBody에서 추출
const bodyText = selectedMailDetail.textBody
|| (selectedMailDetail.htmlBody ? stripHtml(selectedMailDetail.htmlBody) : "");
// console.log('📧 변환된 본문:', bodyText);
const forwardData = {
originalFrom: selectedMailDetail.from,
originalSubject: selectedMailDetail.subject,
originalDate: selectedMailDetail.date,
originalBody: bodyText,
};
router.push(
`/admin/mail/send?action=forward&data=${encodeURIComponent(JSON.stringify(forwardData))}`
);
}}
>
<Forward className="w-4 h-4 mr-1" />
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleDeleteMail}
disabled={deleting}
>
{deleting ? (
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
) : (
<Trash2 className="w-4 h-4 mr-1" />
)}
</Button>
</div>
</CardHeader>
<CardContent className="p-6 max-h-[calc(100vh-300px)] overflow-y-auto">
{/* 첨부파일 */}
{selectedMailDetail.attachments && selectedMailDetail.attachments.length > 0 && (
<div className="mb-4 p-3 bg-muted rounded-lg">
<p className="text-sm font-medium mb-2"> ({selectedMailDetail.attachments.length})</p>
<div className="space-y-1">
{selectedMailDetail.attachments.map((att, index) => (
<div key={index} className="flex items-center gap-2 text-sm">
<Paperclip className="w-4 h-4" />
<span>{att.filename}</span>
<span className="text-muted-foreground">({(att.size / 1024).toFixed(1)} KB)</span>
</div>
))}
</div>
</div>
)}
{/* 메일 본문 */}
{selectedMailDetail.htmlBody ? (
<div
className="prose prose-sm max-w-none"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(selectedMailDetail.htmlBody),
}}
/>
) : (
<div className="whitespace-pre-wrap text-sm">
{selectedMailDetail.textBody}
</div>
)}
</CardContent>
</Card>
) : loadingDetail ? (
<Card className="sticky top-6">
<CardContent className="flex justify-center items-center py-16">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
<span className="ml-3 text-muted-foreground"> ...</span>
</CardContent>
</Card>
) : (
<Card className="sticky top-6">
<CardContent className="flex flex-col justify-center items-center py-16 text-center">
<Mail className="w-16 h-16 mb-4 text-gray-300" />
<p className="text-muted-foreground">
</p>
</CardContent>
</Card>
)}
</div>
</div>
{/* 안내 정보 */} {/* 안내 정보 */}
<Card className="bg-gradient-to-r from-green-50 to-emerald-50 border-green-200 "> <Card className="bg-gradient-to-r from-green-50 to-emerald-50 border-green-200 ">
@ -563,15 +996,6 @@ export default function MailReceivePage() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* 메일 상세 모달 */}
<MailDetailModal
isOpen={isDetailModalOpen}
onClose={() => setIsDetailModalOpen(false)}
accountId={selectedAccountId}
mailId={selectedMailId}
onMailRead={handleMailRead}
/>
</div> </div>
); );
} }

View File

@ -31,7 +31,7 @@ import {
Settings, Settings,
ChevronRight, ChevronRight,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { import {
@ -42,11 +42,14 @@ import {
sendMail, sendMail,
extractTemplateVariables, extractTemplateVariables,
renderTemplateToHtml, renderTemplateToHtml,
saveDraft,
updateDraft,
} from "@/lib/api/mail"; } from "@/lib/api/mail";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
export default function MailSendPage() { export default function MailSendPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const { toast } = useToast(); const { toast } = useToast();
const [accounts, setAccounts] = useState<MailAccount[]>([]); const [accounts, setAccounts] = useState<MailAccount[]>([]);
const [templates, setTemplates] = useState<MailTemplate[]>([]); const [templates, setTemplates] = useState<MailTemplate[]>([]);
@ -66,6 +69,7 @@ export default function MailSendPage() {
const [customHtml, setCustomHtml] = useState<string>(""); const [customHtml, setCustomHtml] = useState<string>("");
const [variables, setVariables] = useState<Record<string, string>>({}); const [variables, setVariables] = useState<Record<string, string>>({});
const [showPreview, setShowPreview] = useState(false); const [showPreview, setShowPreview] = useState(false);
const [isEditingHtml, setIsEditingHtml] = useState(false); // HTML 편집 모드
// 템플릿 변수 // 템플릿 변수
const [templateVariables, setTemplateVariables] = useState<string[]>([]); const [templateVariables, setTemplateVariables] = useState<string[]>([]);
@ -74,9 +78,113 @@ export default function MailSendPage() {
const [attachments, setAttachments] = useState<File[]>([]); const [attachments, setAttachments] = useState<File[]>([]);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
// 임시 저장
const [draftId, setDraftId] = useState<string | null>(null);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const [autoSaving, setAutoSaving] = useState(false);
useEffect(() => { useEffect(() => {
loadData(); loadData();
}, []);
// 답장/전달 데이터 처리
const action = searchParams.get("action");
const dataParam = searchParams.get("data");
if (action && dataParam) {
try {
const data = JSON.parse(decodeURIComponent(dataParam));
if (action === "reply") {
// 답장: 받는사람 자동 입력, 제목에 Re: 추가
const fromEmail = data.originalFrom.match(/<(.+?)>/)?.[1] || data.originalFrom;
setTo([fromEmail]);
setSubject(data.originalSubject.startsWith("Re: ")
? data.originalSubject
: `Re: ${data.originalSubject}`
);
// 원본 메일을 순수 텍스트로 추가 (사용자가 읽기 쉽게)
const originalMessage = `
:
보낸사람: ${data.originalFrom}
날짜: ${new Date(data.originalDate).toLocaleString("ko-KR")}
제목: ${data.originalSubject}
${data.originalBody}`;
setCustomHtml(originalMessage);
toast({
title: '답장 작성',
description: '받는사람과 제목이 자동으로 입력되었습니다.',
});
} else if (action === "forward") {
// 전달: 받는사람 비어있음, 제목에 Fwd: 추가
setSubject(data.originalSubject.startsWith("Fwd: ")
? data.originalSubject
: `Fwd: ${data.originalSubject}`
);
// 원본 메일을 순수 텍스트로 추가 (사용자가 읽기 쉽게)
const originalMessage = `
:
보낸사람: ${data.originalFrom}
날짜: ${new Date(data.originalDate).toLocaleString("ko-KR")}
제목: ${data.originalSubject}
${data.originalBody}`;
setCustomHtml(originalMessage);
toast({
title: '메일 전달',
description: '전달할 메일 내용이 입력되었습니다. 받는사람을 입력하세요.',
});
}
// URL에서 파라미터 제거 (깔끔하게)
router.replace("/admin/mail/send");
} catch (error) {
// console.error("답장/전달 데이터 파싱 실패:", error);
}
return;
}
// 임시 저장 메일 불러오기
const draftIdParam = searchParams.get("draftId");
const toParam = searchParams.get("to");
const ccParam = searchParams.get("cc");
const bccParam = searchParams.get("bcc");
const subjectParam = searchParams.get("subject");
const contentParam = searchParams.get("content");
const accountIdParam = searchParams.get("accountId");
if (draftIdParam) {
setDraftId(draftIdParam);
if (toParam) setTo(toParam.split(",").filter(Boolean));
if (ccParam) setCc(ccParam.split(",").filter(Boolean));
if (bccParam) setBcc(bccParam.split(",").filter(Boolean));
if (subjectParam) setSubject(subjectParam);
if (contentParam) setCustomHtml(contentParam);
if (accountIdParam) setSelectedAccountId(accountIdParam);
toast({
title: '임시 저장 메일 불러오기',
description: '작성 중이던 메일을 불러왔습니다.',
});
return;
}
}, [searchParams]);
const loadData = async () => { const loadData = async () => {
try { try {
@ -85,20 +193,28 @@ export default function MailSendPage() {
getMailAccounts(), getMailAccounts(),
getMailTemplates(), getMailTemplates(),
]); ]);
setAccounts(accountsData.filter((acc) => acc.status === "active")); const activeAccounts = accountsData.filter((acc) => acc.status === "active");
setAccounts(activeAccounts);
setTemplates(templatesData); setTemplates(templatesData);
console.log('📦 데이터 로드 완료:', {
accounts: accountsData.length, // 계정이 선택되지 않았고, 활성 계정이 있으면 첫 번째 계정 자동 선택
templates: templatesData.length, if (!selectedAccountId && activeAccounts.length > 0) {
templatesDetail: templatesData.map(t => ({ setSelectedAccountId(activeAccounts[0].id);
id: t.id, // console.log('🔧 첫 번째 계정 자동 선택:', activeAccounts[0].email);
name: t.name, }
componentsCount: t.components?.length || 0
})) // console.log('📦 데이터 로드 완료:', {
}); // accounts: accountsData.length,
// templates: templatesData.length,
// templatesDetail: templatesData.map(t => ({
// id: t.id,
// name: t.name,
// componentsCount: t.components?.length || 0
// }))
// });
} catch (error: unknown) { } catch (error: unknown) {
const err = error as Error; const err = error as Error;
console.error('❌ 데이터 로드 실패:', err); // console.error('❌ 데이터 로드 실패:', err);
toast({ toast({
title: "데이터 로드 실패", title: "데이터 로드 실패",
description: err.message, description: err.message,
@ -109,13 +225,62 @@ export default function MailSendPage() {
} }
}; };
// 임시 저장 함수
const handleAutoSave = async () => {
if (!selectedAccountId || (!subject && !customHtml && to.length === 0)) {
return; // 저장할 내용이 없으면 스킵
}
try {
setAutoSaving(true);
const draftData = {
accountId: selectedAccountId,
accountName: accounts.find(a => a.id === selectedAccountId)?.name || "",
accountEmail: accounts.find(a => a.id === selectedAccountId)?.email || "",
to,
cc,
bcc,
subject,
htmlContent: customHtml,
templateId: selectedTemplateId || undefined,
};
if (draftId) {
// 기존 임시 저장 업데이트
await updateDraft(draftId, draftData);
} else {
// 새로운 임시 저장
const savedDraft = await saveDraft(draftData);
if (savedDraft && savedDraft.id) {
setDraftId(savedDraft.id);
}
}
setLastSaved(new Date());
} catch (error) {
// console.error('임시 저장 실패:', error);
} finally {
setAutoSaving(false);
}
};
// 30초마다 자동 저장
useEffect(() => {
const interval = setInterval(() => {
handleAutoSave();
}, 30000); // 30초
return () => clearInterval(interval);
}, [selectedAccountId, to, cc, bcc, subject, customHtml, selectedTemplateId, draftId]);
// 템플릿 선택 시 (원본 다시 로드) // 템플릿 선택 시 (원본 다시 로드)
const handleTemplateChange = async (templateId: string) => { const handleTemplateChange = async (templateId: string) => {
console.log('🔄 템플릿 선택됨:', templateId); console.log('🔄 템플릿 선택됨:', templateId);
// "__custom__"는 직접 작성을 의미 // "__custom__"는 직접 작성을 의미
if (templateId === "__custom__") { if (templateId === "__custom__") {
console.log('✏️ 직접 작성 모드'); // console.log('✏️ 직접 작성 모드');
setSelectedTemplateId(""); setSelectedTemplateId("");
setTemplateVariables([]); setTemplateVariables([]);
setVariables({}); setVariables({});
@ -124,20 +289,20 @@ export default function MailSendPage() {
try { try {
// 🎯 원본 템플릿을 API에서 다시 로드 (수정사항 초기화) // 🎯 원본 템플릿을 API에서 다시 로드 (수정사항 초기화)
console.log('🔃 원본 템플릿 API에서 재로드 중...'); // console.log('🔃 원본 템플릿 API에서 재로드 중...');
const freshTemplates = await getMailTemplates(); const freshTemplates = await getMailTemplates();
const template = freshTemplates.find((t) => t.id === templateId); const template = freshTemplates.find((t) => t.id === templateId);
console.log('📋 찾은 템플릿:', { // console.log('📋 찾은 템플릿:', {
found: !!template, // found: !!template,
templateId, // templateId,
availableTemplates: freshTemplates.length, // availableTemplates: freshTemplates.length,
template: template ? { // template: template ? {
id: template.id, // id: template.id,
name: template.name, // name: template.name,
componentsCount: template.components?.length || 0 // componentsCount: template.components?.length || 0
} : null // } : null
}); // });
if (template) { if (template) {
// 🎯 templates state도 원본으로 업데이트 (깨끗한 상태) // 🎯 templates state도 원본으로 업데이트 (깨끗한 상태)
@ -153,18 +318,18 @@ export default function MailSendPage() {
}); });
setVariables(initialVars); setVariables(initialVars);
console.log('✅ 원본 템플릿 적용 완료 (깨끗한 상태):', { // console.log('✅ 원본 템플릿 적용 완료 (깨끗한 상태):', {
subject: template.subject, // subject: template.subject,
variables: vars // variables: vars
}); // });
} else { } else {
setSelectedTemplateId(""); setSelectedTemplateId("");
setTemplateVariables([]); setTemplateVariables([]);
setVariables({}); setVariables({});
console.warn('⚠️ 템플릿을 찾을 수 없음'); // console.warn('⚠️ 템플릿을 찾을 수 없음');
} }
} catch (error) { } catch (error) {
console.error('❌ 템플릿 재로드 실패:', error); // console.error('❌ 템플릿 재로드 실패:', error);
toast({ toast({
title: "템플릿 로드 실패", title: "템플릿 로드 실패",
description: "템플릿을 불러오는 중 오류가 발생했습니다.", description: "템플릿을 불러오는 중 오류가 발생했습니다.",
@ -228,7 +393,7 @@ export default function MailSendPage() {
.join(''); .join('');
return ` return `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;"> <div style="font-family: Arial, sans-serif; padding: 20px; color: #333;">
${html} ${html}
</div> </div>
`; `;
@ -275,8 +440,12 @@ export default function MailSendPage() {
try { try {
setSending(true); setSending(true);
// 텍스트를 HTML로 자동 변환 // HTML 변환
const htmlContent = customHtml ? convertTextToHtml(customHtml) : undefined; let htmlContent = undefined;
if (customHtml.trim()) {
// 일반 텍스트를 HTML로 변환
htmlContent = convertTextToHtml(customHtml);
}
// FormData 생성 (파일 첨부 지원) // FormData 생성 (파일 첨부 지원)
const formData = new FormData(); const formData = new FormData();
@ -288,7 +457,7 @@ export default function MailSendPage() {
const currentTemplate = templates.find((t) => t.id === selectedTemplateId); const currentTemplate = templates.find((t) => t.id === selectedTemplateId);
if (currentTemplate) { if (currentTemplate) {
formData.append("modifiedTemplateComponents", JSON.stringify(currentTemplate.components)); formData.append("modifiedTemplateComponents", JSON.stringify(currentTemplate.components));
console.log('📤 수정된 템플릿 컴포넌트 전송:', currentTemplate.components.length); // console.log('📤 수정된 템플릿 컴포넌트 전송:', currentTemplate.components.length);
} }
} }
formData.append("to", JSON.stringify(to)); formData.append("to", JSON.stringify(to));
@ -316,11 +485,11 @@ export default function MailSendPage() {
const originalFileNames = attachments.map(file => { const originalFileNames = attachments.map(file => {
// 파일명 정규화 (NFD → NFC) // 파일명 정규화 (NFD → NFC)
const normalizedName = file.name.normalize('NFC'); const normalizedName = file.name.normalize('NFC');
console.log('📎 파일명 정규화:', file.name, '->', normalizedName); // console.log('📎 파일명 정규화:', file.name, '->', normalizedName);
return normalizedName; return normalizedName;
}); });
formData.append("fileNames", JSON.stringify(originalFileNames)); formData.append("fileNames", JSON.stringify(originalFileNames));
console.log('📎 전송할 정규화된 파일명들:', originalFileNames); // console.log('📎 전송할 정규화된 파일명들:', originalFileNames);
} }
// API 호출 (FormData 전송) // API 호출 (FormData 전송)
@ -354,6 +523,9 @@ export default function MailSendPage() {
className: "border-green-500 bg-green-50", className: "border-green-500 bg-green-50",
}); });
// 알림 갱신 이벤트 발생
window.dispatchEvent(new CustomEvent('mail-sent'));
// 폼 초기화 // 폼 초기화
setTo([]); setTo([]);
setCc([]); setCc([]);
@ -383,6 +555,58 @@ export default function MailSendPage() {
} }
}; };
// 임시 저장
const handleSaveDraft = async () => {
try {
setAutoSaving(true);
const account = accounts.find(a => a.id === selectedAccountId);
const draftData = {
accountId: selectedAccountId,
accountName: account?.name || "",
accountEmail: account?.email || "",
to,
cc,
bcc,
subject,
htmlContent: customHtml,
templateId: selectedTemplateId || undefined,
};
// console.log('💾 임시 저장 데이터:', draftData);
if (draftId) {
// 기존 임시 저장 업데이트
await updateDraft(draftId, draftData);
// console.log('✏️ 임시 저장 업데이트 완료:', draftId);
} else {
// 새로운 임시 저장
const savedDraft = await saveDraft(draftData);
// console.log('💾 임시 저장 완료:', savedDraft);
if (savedDraft && savedDraft.id) {
setDraftId(savedDraft.id);
}
}
setLastSaved(new Date());
toast({
title: "임시 저장 완료",
description: "작성 중인 메일이 저장되었습니다.",
});
} catch (error: unknown) {
const err = error as Error;
// console.error('❌ 임시 저장 실패:', err);
toast({
title: "임시 저장 실패",
description: err.message || "임시 저장 중 오류가 발생했습니다.",
variant: "destructive",
});
} finally {
setAutoSaving(false);
}
};
// 파일 첨부 관련 함수 // 파일 첨부 관련 함수
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
@ -477,15 +701,15 @@ export default function MailSendPage() {
if (selectedTemplateId) { if (selectedTemplateId) {
const template = templates.find((t) => t.id === selectedTemplateId); const template = templates.find((t) => t.id === selectedTemplateId);
if (template) { if (template) {
console.log('🎨 템플릿 미리보기:', { // console.log('🎨 템플릿 미리보기:', {
templateId: selectedTemplateId, // templateId: selectedTemplateId,
templateName: template.name, // templateName: template.name,
componentsCount: template.components?.length || 0, // componentsCount: template.components?.length || 0,
components: template.components, // components: template.components,
variables // variables
}); // });
const html = renderTemplateToHtml(template, variables); const html = renderTemplateToHtml(template, variables);
console.log('📄 생성된 HTML:', html.substring(0, 200) + '...'); // console.log('📄 생성된 HTML:', html.substring(0, 200) + '...');
// 추가 메시지가 있으면 병합 // 추가 메시지가 있으면 병합
if (customHtml && customHtml.trim()) { if (customHtml && customHtml.trim()) {
@ -531,9 +755,72 @@ export default function MailSendPage() {
<Separator /> <Separator />
{/* 제목 */} {/* 제목 */}
<div> <div className="flex items-center justify-between">
<h1 className="text-3xl font-bold text-foreground"> </h1> <div>
<p className="mt-2 text-muted-foreground">릿 </p> <h1 className="text-3xl font-bold text-foreground">
{subject.startsWith("Re: ") ? "답장 작성" : subject.startsWith("Fwd: ") ? "메일 전달" : "메일 발송"}
</h1>
<p className="mt-2 text-muted-foreground">
{subject.startsWith("Re: ")
? "받은 메일에 답장을 작성합니다"
: subject.startsWith("Fwd: ")
? "메일을 다른 사람에게 전달합니다"
: "템플릿을 선택하거나 직접 작성하여 메일을 발송하세요"}
</p>
</div>
<div className="flex items-center gap-3">
{/* 임시 저장 표시 */}
{lastSaved && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{autoSaving ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span> ...</span>
</>
) : (
<>
<CheckCircle2 className="w-4 h-4 text-green-500" />
<span>
{new Date(lastSaved).toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})}
</span>
</>
)}
</div>
)}
{/* 임시 저장 버튼 */}
<Button
onClick={handleSaveDraft}
variant="outline"
size="sm"
disabled={autoSaving}
>
{autoSaving ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
...
</>
) : (
<>
<FileText className="w-4 h-4 mr-1" />
</>
)}
</Button>
{/* 임시 저장 목록 버튼 */}
<Link href="/admin/mail/drafts">
<Button variant="outline" size="sm">
<Mail className="w-4 h-4 mr-1" />
</Button>
</Link>
</div>
</div> </div>
</div> </div>
@ -957,30 +1244,41 @@ export default function MailSendPage() {
return null; return null;
})()} })()}
{/* 메일 내용 입력 - 항상 표시 */} {/* 메일 내용 입력 */}
<div> {!showPreview && !selectedTemplateId && (
<Label htmlFor="customHtml"> <div>
{selectedTemplateId ? "추가 메시지 (선택)" : "내용 *"} <Label htmlFor="customHtml"> *</Label>
</Label> <Textarea
<Textarea id="customHtml"
id="customHtml" value={customHtml}
value={customHtml} onChange={(e) => setCustomHtml(e.target.value)}
onChange={(e) => setCustomHtml(e.target.value)} placeholder="메일 내용을 입력하세요&#10;&#10;줄바꿈은 자동으로 처리됩니다."
placeholder={ rows={12}
selectedTemplateId className="resize-none"
? "템플릿 하단에 추가될 내용을 입력하세요 (선택사항)" />
: "메일 내용을 입력하세요\n\n줄바꿈은 자동으로 처리됩니다." <p className="text-xs text-muted-foreground mt-1">
} 💡
rows={10} </p>
/> </div>
<p className="text-xs text-muted-foreground mt-1"> )}
{selectedTemplateId ? (
<>💡 릿 </> {/* 템플릿 선택 시 추가 메시지 */}
) : ( {!showPreview && selectedTemplateId && (
<>💡 </> <div>
)} <Label htmlFor="customHtml"> ()</Label>
</p> <Textarea
</div> id="customHtml"
value={customHtml}
onChange={(e) => setCustomHtml(e.target.value)}
placeholder="템플릿 하단에 추가될 내용을 입력하세요 (선택사항)"
rows={6}
className="resize-none"
/>
<p className="text-xs text-muted-foreground mt-1">
💡 릿
</p>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
@ -1100,13 +1398,23 @@ export default function MailSendPage() {
<Eye className="w-5 h-5" /> <Eye className="w-5 h-5" />
</div> </div>
<Button <div className="flex items-center gap-2">
variant="ghost" <Button
size="sm" variant="outline"
onClick={() => setShowPreview(false)} size="sm"
> onClick={() => setIsEditingHtml(!isEditingHtml)}
<X className="w-4 h-4" /> >
</Button> {isEditingHtml ? <Eye className="w-4 h-4 mr-1" /> : <Settings className="w-4 h-4 mr-1" />}
{isEditingHtml ? "미리보기" : "편집"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setShowPreview(false)}
>
<X className="w-4 h-4" />
</Button>
</div>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -1143,7 +1451,17 @@ export default function MailSendPage() {
</div> </div>
)} )}
</div> </div>
<div dangerouslySetInnerHTML={{ __html: getPreviewHtml() }} /> {isEditingHtml ? (
<Textarea
value={customHtml}
onChange={(e) => setCustomHtml(e.target.value)}
rows={20}
className="font-mono text-xs"
placeholder="HTML 코드를 직접 편집할 수 있습니다"
/>
) : (
<div dangerouslySetInnerHTML={{ __html: getPreviewHtml() }} />
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

File diff suppressed because it is too large Load Diff

View File

@ -42,7 +42,7 @@ export default function MailTemplatesPage() {
const data = await getMailTemplates(); const data = await getMailTemplates();
setTemplates(data); setTemplates(data);
} catch (error) { } catch (error) {
console.error('템플릿 로드 실패:', error); // console.error('템플릿 로드 실패:', error);
alert('템플릿 목록을 불러오는데 실패했습니다.'); alert('템플릿 목록을 불러오는데 실패했습니다.');
} finally { } finally {
setLoading(false); setLoading(false);
@ -110,7 +110,7 @@ export default function MailTemplatesPage() {
await loadTemplates(); await loadTemplates();
alert('템플릿이 삭제되었습니다.'); alert('템플릿이 삭제되었습니다.');
} catch (error) { } catch (error) {
console.error('템플릿 삭제 실패:', error); // console.error('템플릿 삭제 실패:', error);
alert('템플릿 삭제에 실패했습니다.'); alert('템플릿 삭제에 실패했습니다.');
} }
}; };
@ -126,7 +126,7 @@ export default function MailTemplatesPage() {
await loadTemplates(); await loadTemplates();
alert('템플릿이 복사되었습니다.'); alert('템플릿이 복사되었습니다.');
} catch (error) { } catch (error) {
console.error('템플릿 복사 실패:', error); // console.error('템플릿 복사 실패:', error);
alert('템플릿 복사에 실패했습니다.'); alert('템플릿 복사에 실패했습니다.');
} }
}; };

View File

@ -0,0 +1,192 @@
"use client";
import { useState, useEffect } from "react";
import { getSentMailList, restoreMail, permanentlyDeleteMail, type SentMailHistory } from "@/lib/api/mail";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { RotateCcw, Trash2, Loader2, Mail, AlertCircle } from "lucide-react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
export default function TrashPage() {
const [trashedMails, setTrashedMails] = useState<SentMailHistory[]>([]);
const [loading, setLoading] = useState(true);
const [restoring, setRestoring] = useState<string | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
useEffect(() => {
loadTrashedMails();
}, []);
const loadTrashedMails = async () => {
try {
setLoading(true);
const response = await getSentMailList({
onlyDeleted: true,
sortBy: "sentAt",
sortOrder: "desc",
});
setTrashedMails(response.items);
} catch (error) {
// console.error("휴지통 메일 로드 실패:", error);
} finally {
setLoading(false);
}
};
const handleRestore = async (id: string) => {
try {
setRestoring(id);
await restoreMail(id);
setTrashedMails(trashedMails.filter((m) => m.id !== id));
} catch (error) {
// console.error("메일 복구 실패:", error);
alert("복구에 실패했습니다.");
} finally {
setRestoring(null);
}
};
const handlePermanentDelete = async (id: string) => {
if (!confirm("이 메일을 영구적으로 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.")) return;
try {
setDeleting(id);
await permanentlyDeleteMail(id);
setTrashedMails(trashedMails.filter((m) => m.id !== id));
} catch (error) {
// console.error("메일 영구 삭제 실패:", error);
alert("삭제에 실패했습니다.");
} finally {
setDeleting(null);
}
};
const handleEmptyTrash = async () => {
if (!confirm(`휴지통의 모든 메일(${trashedMails.length}개)을 영구적으로 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`)) return;
try {
setLoading(true);
await Promise.all(trashedMails.map((mail) => permanentlyDeleteMail(mail.id)));
setTrashedMails([]);
alert("휴지통을 비웠습니다.");
} catch (error) {
// console.error("휴지통 비우기 실패:", error);
alert("일부 메일 삭제에 실패했습니다.");
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
);
}
return (
<div className="p-3 space-y-3">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"> 30 </p>
</div>
{trashedMails.length > 0 && (
<Button variant="destructive" onClick={handleEmptyTrash} className="h-10">
<Trash2 className="w-4 h-4 mr-2" />
</Button>
)}
</div>
{trashedMails.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Mail className="w-12 h-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground"> </p>
</CardContent>
</Card>
) : (
<div className="grid gap-3">
{trashedMails.map((mail) => {
const deletedDate = mail.deletedAt ? new Date(mail.deletedAt) : null;
const daysLeft = deletedDate
? Math.max(0, 30 - Math.floor((Date.now() - deletedDate.getTime()) / (1000 * 60 * 60 * 24)))
: 30;
return (
<Card key={mail.id} className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<CardTitle className="text-lg truncate">
{mail.subject || "(제목 없음)"}
</CardTitle>
<CardDescription className="mt-1">
: {mail.to.join(", ") || "(없음)"}
</CardDescription>
</div>
<div className="flex items-center gap-2 ml-4">
<Button
variant="outline"
size="sm"
onClick={() => handleRestore(mail.id)}
disabled={restoring === mail.id}
className="h-8"
>
{restoring === mail.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
<RotateCcw className="w-4 h-4 mr-1" />
</>
)}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handlePermanentDelete(mail.id)}
disabled={deleting === mail.id}
className="h-8"
>
{deleting === mail.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
<Trash2 className="w-4 h-4 mr-1" />
</>
)}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
: {mail.accountName || mail.accountEmail}
</span>
<span className="text-muted-foreground">
{format(new Date(mail.sentAt), "yyyy-MM-dd HH:mm", { locale: ko })}
</span>
</div>
{daysLeft <= 7 && (
<div className="flex items-center gap-2 mt-2 text-xs text-amber-600">
<AlertCircle className="w-3 h-3" />
<span>{daysLeft} </span>
</div>
)}
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}

View File

@ -17,8 +17,9 @@ import {
Loader2, Loader2,
AlertCircle, AlertCircle,
} from "lucide-react"; } from "lucide-react";
import { MailDetail, getMailDetail, markMailAsRead } from "@/lib/api/mail"; import { MailDetail, getMailDetail, markMailAsRead, downloadMailAttachment } from "@/lib/api/mail";
import DOMPurify from "isomorphic-dompurify"; import DOMPurify from "isomorphic-dompurify";
import { useRouter } from "next/navigation";
interface MailDetailModalProps { interface MailDetailModalProps {
isOpen: boolean; isOpen: boolean;
@ -35,6 +36,7 @@ export default function MailDetailModal({
mailId, mailId,
onMailRead, onMailRead,
}: MailDetailModalProps) { }: MailDetailModalProps) {
const router = useRouter();
const [mail, setMail] = useState<MailDetail | null>(null); const [mail, setMail] = useState<MailDetail | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -146,30 +148,9 @@ export default function MailDetailModal({
try { try {
console.log(`🖼️ 이미지 로드 시작 - index: ${index}`); console.log(`🖼️ 이미지 로드 시작 - index: ${index}`);
const seqno = parseInt(mailId.split("-").pop() || "0", 10); const seqno = parseInt(mailId.split("-").pop() || "0", 10);
const token = localStorage.getItem("authToken");
console.log(`🔑 토큰 확인: ${token ? '있음' : '없음'}`); // API 함수 사용
const blob = await downloadMailAttachment(accountId, seqno, index);
// 🔧 임시: 백엔드 직접 호출 (프록시 우회)
const backendUrl = process.env.NODE_ENV === 'production'
? `http://39.117.244.52:8080/api/mail/receive/${accountId}/${seqno}/attachment/${index}`
: `http://localhost:8080/api/mail/receive/${accountId}/${seqno}/attachment/${index}`;
console.log(`📍 요청 URL: ${backendUrl}`);
const response = await fetch(backendUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
});
console.log(`📡 응답 상태: ${response.status}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
console.log(`✅ Blob URL 생성 완료: ${blobUrl}`); console.log(`✅ Blob URL 생성 완료: ${blobUrl}`);
@ -183,24 +164,9 @@ export default function MailDetailModal({
const handleDownloadAttachment = async (index: number, filename: string) => { const handleDownloadAttachment = async (index: number, filename: string) => {
try { try {
const seqno = parseInt(mailId.split("-").pop() || "0", 10); const seqno = parseInt(mailId.split("-").pop() || "0", 10);
const token = localStorage.getItem("authToken");
// 🔧 임시: 백엔드 직접 호출 (프록시 우회) // API 함수 사용
const backendUrl = process.env.NODE_ENV === 'production' const blob = await downloadMailAttachment(accountId, seqno, index);
? `http://39.117.244.52:8080/api/mail/receive/${accountId}/${seqno}/attachment/${index}`
: `http://localhost:8080/api/mail/receive/${accountId}/${seqno}/attachment/${index}`;
const response = await fetch(backendUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
// 다운로드 트리거 // 다운로드 트리거
@ -272,11 +238,42 @@ export default function MailDetailModal({
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" size="sm"> <Button
variant="outline"
size="sm"
onClick={() => {
if (!mail) return;
const replyData = {
type: 'reply',
originalFrom: mail.from,
originalSubject: mail.subject,
originalDate: mail.date,
originalBody: mail.body,
};
router.push(`/admin/mail/send?action=reply&data=${encodeURIComponent(JSON.stringify(replyData))}`);
onClose();
}}
>
<Reply className="w-4 h-4 mr-2" /> <Reply className="w-4 h-4 mr-2" />
</Button> </Button>
<Button variant="outline" size="sm"> <Button
variant="outline"
size="sm"
onClick={() => {
if (!mail) return;
const forwardData = {
type: 'forward',
originalFrom: mail.from,
originalSubject: mail.subject,
originalDate: mail.date,
originalBody: mail.body,
originalAttachments: mail.attachments,
};
router.push(`/admin/mail/send?action=forward&data=${encodeURIComponent(JSON.stringify(forwardData))}`);
onClose();
}}
>
<Forward className="w-4 h-4 mr-2" /> <Forward className="w-4 h-4 mr-2" />
</Button> </Button>

View File

@ -0,0 +1,381 @@
"use client";
import React, { useState, useEffect } from "react";
import { Bell, Mail, AlertCircle, XCircle, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { getSentMailList, getReceivedMails, getMailAccounts } from "@/lib/api/mail";
import { useRouter } from "next/navigation";
interface MailNotification {
id: string;
type: "new_mail" | "send_failed" | "limit_warning";
title: string;
message: string;
timestamp: string;
read: boolean;
url?: string; // 이동할 URL 추가
}
export default function MailNotifications() {
const router = useRouter();
const [notifications, setNotifications] = useState<MailNotification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const [readNotificationIds, setReadNotificationIds] = useState<Set<string>>(new Set());
const [isInitialized, setIsInitialized] = useState(false);
// localStorage에서 읽은 알림 ID 로드 (최우선)
useEffect(() => {
const stored = localStorage.getItem('readNotificationIds');
if (stored) {
try {
const ids = JSON.parse(stored);
setReadNotificationIds(new Set(ids));
} catch (error) {
console.error('읽은 알림 ID 로드 실패:', error);
}
}
setIsInitialized(true);
}, []);
useEffect(() => {
// localStorage 로드 완료 후에만 알림 로드
if (!isInitialized) return;
// 알림 로드
loadNotifications();
// 5초마다 새 알림 확인 (더 빠른 실시간 업데이트)
const interval = setInterval(() => {
checkNewNotifications();
}, 5000);
// 메일 발송/수신 이벤트 리스너 (즉시 갱신)
const handleMailEvent = () => {
console.log('📧 메일 이벤트 감지 - 알림 갱신');
checkNewNotifications();
};
window.addEventListener('mail-sent', handleMailEvent);
window.addEventListener('mail-received', handleMailEvent);
return () => {
clearInterval(interval);
window.removeEventListener('mail-sent', handleMailEvent);
window.removeEventListener('mail-received', handleMailEvent);
};
}, [isInitialized, readNotificationIds]);
useEffect(() => {
const count = notifications.filter((n) => !n.read).length;
setUnreadCount(count);
}, [notifications]);
// 알림 패널이 열리면 3초 후 자동으로 읽음 처리
useEffect(() => {
if (isOpen && notifications.some((n) => !n.read)) {
const timer = setTimeout(() => {
markAllAsRead();
}, 3000); // 3초 후 자동 읽음 처리
return () => clearTimeout(timer);
}
}, [isOpen, notifications]);
const loadNotifications = async () => {
try {
const newNotifications: MailNotification[] = [];
// 1. 최근 발송 실패한 메일 확인 (최근 1시간)
try {
const sentMails = await getSentMailList({
page: 1,
limit: 20,
status: 'failed',
});
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
sentMails.items?.forEach((mail) => {
const sentDate = new Date(mail.sentAt);
if (sentDate > oneHourAgo) {
newNotifications.push({
id: `failed-${mail.id}`,
type: "send_failed",
title: "메일 발송 실패",
message: `${mail.to.join(', ')}에게 보낸 메일이 실패했습니다.`,
timestamp: mail.sentAt,
read: false,
url: '/admin/mail/sent', // 보낸메일함으로 이동
});
}
});
} catch (error) {
console.error('발송 실패 메일 확인 오류:', error);
}
// 2. 최근 수신 메일 확인 (최근 30분)
try {
const accounts = await getMailAccounts();
const activeAccounts = accounts.filter((acc) => acc.status === 'active');
for (const account of activeAccounts) {
try {
const receivedMails = await getReceivedMails(account.id, 10);
const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000);
receivedMails.forEach((mail) => {
const receivedDate = new Date(mail.date);
if (receivedDate > thirtyMinutesAgo && !mail.isRead) {
newNotifications.push({
id: `new-${mail.id}`,
type: "new_mail",
title: "새 메일 도착",
message: `${mail.from}에서 메일이 도착했습니다: ${mail.subject}`,
timestamp: mail.date,
read: false,
url: `/admin/mail/receive?mailId=${mail.id}&accountId=${account.id}`, // 특정 메일로 이동
});
}
});
} catch (error) {
console.error(`계정 ${account.id} 수신 메일 확인 오류:`, error);
}
}
} catch (error) {
console.error('수신 메일 확인 오류:', error);
}
// 3. 일일 발송 제한 경고 확인
try {
const accounts = await getMailAccounts();
const sentMails = await getSentMailList({
page: 1,
limit: 100,
});
accounts.forEach((account) => {
if (account.status === 'active' && account.dailyLimit) {
const todaySentCount = sentMails.items?.filter((mail) => {
const sentDate = new Date(mail.sentAt);
const today = new Date();
return (
mail.accountId === account.id &&
sentDate.toDateString() === today.toDateString()
);
}).length || 0;
const usagePercent = (todaySentCount / account.dailyLimit) * 100;
if (usagePercent >= 80) {
newNotifications.push({
id: `limit-${account.id}`,
type: "limit_warning",
title: "일일 발송 제한 경고",
message: `${account.name} 계정이 일일 제한의 ${usagePercent.toFixed(0)}%를 사용했습니다 (${todaySentCount}/${account.dailyLimit})`,
timestamp: new Date().toISOString(),
read: false,
url: '/admin/mail/accounts', // 계정 관리로 이동
});
}
}
});
} catch (error) {
console.error('일일 제한 확인 오류:', error);
}
// 최신순 정렬
newNotifications.sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
// 읽은 알림 표시 적용
const notificationsWithReadStatus = newNotifications.map((notification) => ({
...notification,
read: readNotificationIds.has(notification.id),
}));
setNotifications(notificationsWithReadStatus);
} catch (error) {
console.error('알림 로드 실패:', error);
}
};
const checkNewNotifications = () => {
loadNotifications();
};
const markAsRead = (id: string) => {
const newReadIds = new Set(readNotificationIds);
newReadIds.add(id);
setReadNotificationIds(newReadIds);
// localStorage에 저장
localStorage.setItem('readNotificationIds', JSON.stringify(Array.from(newReadIds)));
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, read: true } : n))
);
};
const handleNotificationClick = (notification: MailNotification) => {
// 읽음 처리
markAsRead(notification.id);
// URL이 있으면 해당 페이지로 이동
if (notification.url) {
router.push(notification.url);
setIsOpen(false); // 팝오버 닫기
}
};
const markAllAsRead = () => {
const allIds = new Set([...readNotificationIds, ...notifications.map((n) => n.id)]);
setReadNotificationIds(allIds);
// localStorage에 저장
localStorage.setItem('readNotificationIds', JSON.stringify(Array.from(allIds)));
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
};
const clearAll = () => {
// 모든 알림을 읽음으로 표시하고 localStorage에 저장
const allIds = new Set([...readNotificationIds, ...notifications.map((n) => n.id)]);
setReadNotificationIds(allIds);
localStorage.setItem('readNotificationIds', JSON.stringify(Array.from(allIds)));
setNotifications([]);
};
const getIcon = (type: MailNotification["type"]) => {
switch (type) {
case "new_mail":
return <Mail className="h-4 w-4 text-blue-600" />;
case "send_failed":
return <XCircle className="h-4 w-4 text-red-600" />;
case "limit_warning":
return <AlertCircle className="h-4 w-4 text-yellow-600" />;
default:
return <Bell className="h-4 w-4" />;
}
};
const getTypeColor = (type: MailNotification["type"]) => {
switch (type) {
case "new_mail":
return "bg-blue-50 border-blue-200";
case "send_failed":
return "bg-red-50 border-red-200";
case "limit_warning":
return "bg-yellow-50 border-yellow-200";
default:
return "bg-muted";
}
};
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="icon" className="relative">
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<Badge
variant="destructive"
className="absolute -right-1 -top-1 h-5 w-5 rounded-full p-0 text-xs flex items-center justify-center"
>
{unreadCount > 9 ? "9+" : unreadCount}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-96 p-0" align="end">
<div className="flex items-center justify-between p-4 border-b">
<h3 className="font-semibold text-base"></h3>
<div className="flex gap-2">
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={markAllAsRead}
className="text-xs"
>
</Button>
)}
{notifications.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={clearAll}
className="text-xs"
>
</Button>
)}
</div>
</div>
<div className="max-h-96 overflow-y-auto">
{notifications.filter((n) => !n.read).length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
</p>
</div>
) : (
<div className="divide-y">
{notifications.filter((n) => !n.read).map((notification) => (
<div
key={notification.id}
className={`p-4 hover:bg-muted/50 transition-colors cursor-pointer ${
!notification.read ? "bg-muted/30" : ""
}`}
onClick={() => handleNotificationClick(notification)}
>
<div className="flex items-start gap-3">
<div
className={`p-2 rounded-lg ${getTypeColor(notification.type)}`}
>
{getIcon(notification.type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<h4 className="font-medium text-sm truncate">
{notification.title}
</h4>
{!notification.read && (
<div className="w-2 h-2 bg-blue-600 rounded-full flex-shrink-0" />
)}
</div>
<p className="text-xs text-muted-foreground line-clamp-2">
{notification.message}
</p>
<p className="text-xs text-muted-foreground mt-1">
{new Date(notification.timestamp).toLocaleString("ko-KR", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}

View File

@ -3,6 +3,9 @@
* 릿 * 릿
*/ */
// API 기본 URL
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "/api";
// ============================================ // ============================================
// 타입 정의 // 타입 정의
// ============================================ // ============================================
@ -104,23 +107,28 @@ export interface SentMailHistory {
templateName?: string; templateName?: string;
attachments?: AttachmentInfo[]; attachments?: AttachmentInfo[];
sentAt: string; sentAt: string;
status: 'success' | 'failed'; status: 'success' | 'failed' | 'draft';
messageId?: string; messageId?: string;
errorMessage?: string; errorMessage?: string;
accepted?: string[]; accepted?: string[];
rejected?: string[]; rejected?: string[];
isDraft?: boolean;
deletedAt?: string;
updatedAt?: string;
} }
export interface SentMailListQuery { export interface SentMailListQuery {
page?: number; page?: number;
limit?: number; limit?: number;
searchTerm?: string; searchTerm?: string;
status?: 'success' | 'failed' | 'all'; status?: 'success' | 'failed' | 'draft' | 'all';
accountId?: string; accountId?: string;
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
sortBy?: 'sentAt' | 'subject'; sortBy?: 'sentAt' | 'subject' | 'updatedAt';
sortOrder?: 'asc' | 'desc'; sortOrder?: 'asc' | 'desc';
includeDeleted?: boolean;
onlyDeleted?: boolean;
} }
export interface SentMailListResponse { export interface SentMailListResponse {
@ -335,6 +343,53 @@ export async function sendMail(data: SendMailDto): Promise<MailSendResult> {
}); });
} }
/**
*
*/
export interface BulkSendRequest {
accountId: string;
templateId?: string; // 템플릿 ID (선택)
customHtml?: string; // 직접 작성한 HTML (선택)
subject: string;
recipients: Array<{
email: string;
variables?: Record<string, string>; // 템플릿 사용 시에만 필요
}>;
onProgress?: (sent: number, total: number) => void;
}
export interface BulkSendResult {
total: number;
success: number;
failed: number;
results: Array<{
email: string;
success: boolean;
messageId?: string;
error?: string;
}>;
}
export async function sendBulkMail(request: BulkSendRequest): Promise<BulkSendResult> {
const { onProgress, ...data } = request;
// 프로그레스 콜백이 있으면 시뮬레이션 (실제로는 서버에서 스트리밍 필요)
if (onProgress) {
onProgress(0, data.recipients.length);
}
const result = await fetchApi<BulkSendResult>('/mail/send/bulk', {
method: 'POST',
data,
});
if (onProgress) {
onProgress(result.success, result.total);
}
return result;
}
/** /**
* 릿 (릿 {} ) * 릿 (릿 {} )
*/ */
@ -490,6 +545,31 @@ export async function getMailDetail(
return fetchApi<MailDetail>(`/mail/receive/${accountId}/${seqno}`); return fetchApi<MailDetail>(`/mail/receive/${accountId}/${seqno}`);
} }
/**
*
*/
export async function downloadMailAttachment(
accountId: string,
seqno: number,
attachmentIndex: number
): Promise<Blob> {
const token = localStorage.getItem('authToken');
const response = await fetch(
`${API_BASE_URL}/mail/receive/${accountId}/${seqno}/attachment/${attachmentIndex}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
if (!response.ok) {
throw new Error(`첨부파일 다운로드 실패: ${response.status}`);
}
return response.blob();
}
/** /**
* *
*/ */
@ -534,6 +614,8 @@ export async function getSentMailList(
if (query.endDate) params.append('endDate', query.endDate); if (query.endDate) params.append('endDate', query.endDate);
if (query.sortBy) params.append('sortBy', query.sortBy); if (query.sortBy) params.append('sortBy', query.sortBy);
if (query.sortOrder) params.append('sortOrder', query.sortOrder); if (query.sortOrder) params.append('sortOrder', query.sortOrder);
if (query.includeDeleted) params.append('includeDeleted', 'true');
if (query.onlyDeleted) params.append('onlyDeleted', 'true');
return fetchApi(`/mail/sent?${params.toString()}`); return fetchApi(`/mail/sent?${params.toString()}`);
} }
@ -546,7 +628,7 @@ export async function getSentMailById(id: string): Promise<SentMailHistory> {
} }
/** /**
* * (Soft Delete)
*/ */
export async function deleteSentMail(id: string): Promise<{ success: boolean; message: string }> { export async function deleteSentMail(id: string): Promise<{ success: boolean; message: string }> {
return fetchApi(`/mail/sent/${id}`, { return fetchApi(`/mail/sent/${id}`, {
@ -554,6 +636,70 @@ export async function deleteSentMail(id: string): Promise<{ success: boolean; me
}); });
} }
/**
* (Draft)
*/
export async function saveDraft(data: Partial<SentMailHistory> & { accountId: string }): Promise<SentMailHistory> {
const response = await apiClient.post('/mail/sent/draft', data);
return response.data.data;
}
/**
*
*/
export async function updateDraft(id: string, data: Partial<SentMailHistory>): Promise<SentMailHistory> {
const response = await apiClient.put(`/mail/sent/draft/${id}`, data);
return response.data.data;
}
/**
*
*/
export async function restoreMail(id: string): Promise<{ success: boolean; message: string }> {
return fetchApi(`/mail/sent/${id}/restore`, {
method: 'POST',
});
}
/**
*
*/
export async function permanentlyDeleteMail(id: string): Promise<{ success: boolean; message: string }> {
return fetchApi(`/mail/sent/${id}/permanent`, {
method: 'DELETE',
});
}
/**
*
*/
export async function bulkDeleteMails(ids: string[]): Promise<{ success: boolean; message: string; data: { successCount: number; failCount: number } }> {
return fetchApi('/mail/sent/bulk/delete', {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
/**
*
*/
export async function bulkPermanentlyDeleteMails(ids: string[]): Promise<{ success: boolean; message: string; data: { successCount: number; failCount: number } }> {
return fetchApi('/mail/sent/bulk/permanent-delete', {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
/**
*
*/
export async function bulkRestoreMails(ids: string[]): Promise<{ success: boolean; message: string; data: { successCount: number; failCount: number } }> {
return fetchApi('/mail/sent/bulk/restore', {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
/** /**
* *
*/ */