diff --git a/backend-node/data/mail-sent/12b583c9-a6b2-4c7f-8340-fd0e700aa32e.json b/backend-node/data/mail-sent/12b583c9-a6b2-4c7f-8340-fd0e700aa32e.json new file mode 100644 index 00000000..9e7a209c --- /dev/null +++ b/backend-node/data/mail-sent/12b583c9-a6b2-4c7f-8340-fd0e700aa32e.json @@ -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
\r\n

ㄴㅇㄹㄴㅇㄹㄴㅇㄹㅇ리'ㅐㅔ'ㅑ678463ㅎㄱ휼췇흍츄

\r\n
\r\n

\r\n
\r\n

---------- 전달된 메시지 ----------

\r\n

보낸 사람: \"이희진\"

\r\n

날짜: 2025. 10. 22. 오후 1:32:34

\r\n

제목: ㅏㅣ

\r\n
\r\n undefined\r\n
\r\n ", + "status": "success", + "messageId": "<74dbd467-6185-024d-dd60-bf4459ff9ea4@wace.me>", + "accepted": [ + "zian9227@naver.com" + ], + "rejected": [], + "deletedAt": "2025-10-22T06:36:10.876Z" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/1bb5ebfe-3f6c-4884-a043-161ae3f74f75.json b/backend-node/data/mail-sent/1bb5ebfe-3f6c-4884-a043-161ae3f74f75.json new file mode 100644 index 00000000..2f624e9c --- /dev/null +++ b/backend-node/data/mail-sent/1bb5ebfe-3f6c-4884-a043-161ae3f74f75.json @@ -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보낸사람: \"이희진\" \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" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/1d997eeb-3d61-427d-8b54-119d4372b9b3.json b/backend-node/data/mail-sent/1d997eeb-3d61-427d-8b54-119d4372b9b3.json new file mode 100644 index 00000000..683ad20c --- /dev/null +++ b/backend-node/data/mail-sent/1d997eeb-3d61-427d-8b54-119d4372b9b3.json @@ -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
\r\n

전달히야야양


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
전달된 메일:

보낸사람: \"이희진\"
날짜: 2025. 10. 22. 오후 12:58:15
제목: ㄴ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

ㄴㅇㄹㄴㅇㄹㄴㅇㄹ

\r\n
\r\n ", + "status": "success", + "messageId": "", + "accepted": [ + "zian9227@naver.com" + ], + "rejected": [] +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/331d95d6-3a13-4657-bc75-ab0811712eb8.json b/backend-node/data/mail-sent/331d95d6-3a13-4657-bc75-ab0811712eb8.json new file mode 100644 index 00000000..5090fdd2 --- /dev/null +++ b/backend-node/data/mail-sent/331d95d6-3a13-4657-bc75-ab0811712eb8.json @@ -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
\r\n

ㅁㄴㅇㄹㅁㄴㅇㄹㄴㅇㄹㄴㅇㄹ

\r\n
\r\n ", + "status": "success", + "messageId": "", + "accepted": [ + "zian9227@naver.com" + ], + "rejected": [] +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/375f2326-ca86-468a-bfc3-2d4c3825577b.json b/backend-node/data/mail-sent/375f2326-ca86-468a-bfc3-2d4c3825577b.json new file mode 100644 index 00000000..c142808d --- /dev/null +++ b/backend-node/data/mail-sent/375f2326-ca86-468a-bfc3-2d4c3825577b.json @@ -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": [ + "\"이희진\" " + ], + "subject": "Re: ㅏㅣ", + "htmlContent": "\r\n
\r\n

ㅁㄴㅇㄹㅁㅇㄴㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㄴㅁㅇㄹ

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"이희진\"

\r\n

날짜: 2025. 10. 22. 오후 1:32:34

\r\n

제목: ㅏㅣ

\r\n
\r\n undefined\r\n
\r\n ", + "status": "success", + "messageId": "", + "accepted": [ + "zian9227@naver.com" + ], + "rejected": [], + "deletedAt": "2025-10-22T07:11:04.666Z" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/386e334a-df76-440c-ae8a-9bf06982fdc8.json b/backend-node/data/mail-sent/386e334a-df76-440c-ae8a-9bf06982fdc8.json new file mode 100644 index 00000000..31da5552 --- /dev/null +++ b/backend-node/data/mail-sent/386e334a-df76-440c-ae8a-9bf06982fdc8.json @@ -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

\n
\n

---------- 전달된 메일 ----------

\n

보낸사람: \"이희진\" <zian9227@naver.com>

\n

날짜: 2025. 10. 22. 오후 12:58:15

\n

제목:

\n
\n

ㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n

\n
\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" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/3d411dc4-69a6-4236-b878-9693dff881be.json b/backend-node/data/mail-sent/3d411dc4-69a6-4236-b878-9693dff881be.json new file mode 100644 index 00000000..aa107de7 --- /dev/null +++ b/backend-node/data/mail-sent/3d411dc4-69a6-4236-b878-9693dff881be.json @@ -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

\n
\n

원본 메일:

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 22. 오후 12:58:15

\n

제목:

\n
\n

undefined

\n
\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" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/3e30a264-8431-44c7-96ef-eed551e66a11.json b/backend-node/data/mail-sent/3e30a264-8431-44c7-96ef-eed551e66a11.json new file mode 100644 index 00000000..d824d67b --- /dev/null +++ b/backend-node/data/mail-sent/3e30a264-8431-44c7-96ef-eed551e66a11.json @@ -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

\n
\n

---------- 전달된 메일 ----------

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 22. 오후 12:58:15

\n

제목:

\n
\n

\n
\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" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/4a32bab5-364e-4037-bb00-31d2905824db.json b/backend-node/data/mail-sent/4a32bab5-364e-4037-bb00-31d2905824db.json new file mode 100644 index 00000000..92de4a0c --- /dev/null +++ b/backend-node/data/mail-sent/4a32bab5-364e-4037-bb00-31d2905824db.json @@ -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" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/5bfb2acd-023a-4865-a738-2900179db5fb.json b/backend-node/data/mail-sent/5bfb2acd-023a-4865-a738-2900179db5fb.json new file mode 100644 index 00000000..5f5a5cfc --- /dev/null +++ b/backend-node/data/mail-sent/5bfb2acd-023a-4865-a738-2900179db5fb.json @@ -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

\n
\n

---------- 전달된 메일 ----------

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 22. 오후 12:58:15

\n

제목:

\n
\n

ㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n

\n
\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" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/683c1323-1895-403a-bb9a-4e111a8909f6.json b/backend-node/data/mail-sent/683c1323-1895-403a-bb9a-4e111a8909f6.json new file mode 100644 index 00000000..b3c3259f --- /dev/null +++ b/backend-node/data/mail-sent/683c1323-1895-403a-bb9a-4e111a8909f6.json @@ -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

\n
\n

원본 메일:

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 22. 오후 12:58:15

\n

제목:

\n
\n

undefined

\n
\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" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/7bed27d5-dae4-4ba8-85d0-c474c4fb907a.json b/backend-node/data/mail-sent/7bed27d5-dae4-4ba8-85d0-c474c4fb907a.json new file mode 100644 index 00000000..d9edbdeb --- /dev/null +++ b/backend-node/data/mail-sent/7bed27d5-dae4-4ba8-85d0-c474c4fb907a.json @@ -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

\n
\n

---------- 전달된 메일 ----------

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 22. 오후 1:32:34

\n

제목: ㅏㅣ

\n
\n undefined\n
\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" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/84ee9619-49ff-4f61-a7fa-0bb0b0b7199a.json b/backend-node/data/mail-sent/84ee9619-49ff-4f61-a7fa-0bb0b0b7199a.json new file mode 100644 index 00000000..37317a6a --- /dev/null +++ b/backend-node/data/mail-sent/84ee9619-49ff-4f61-a7fa-0bb0b0b7199a.json @@ -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": [ + "\"이희진\" " + ], + "subject": "Re: ㅅㄷㄴㅅ", + "htmlContent": "\r\n
\r\n

야야야야야야야야ㅑㅇ야ㅑㅇ

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"이희진\"

\r\n

날짜: 2025. 10. 22. 오후 1:03:03

\r\n

제목: ㅅㄷㄴㅅ

\r\n
\r\n undefined\r\n
\r\n ", + "status": "success", + "messageId": "<5fa451ff-7d29-7da4-ce56-ca7391c147af@wace.me>", + "accepted": [ + "zian9227@naver.com" + ], + "rejected": [] +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/8990ea86-3112-4e7c-b3e0-8b494181c4e0.json b/backend-node/data/mail-sent/8990ea86-3112-4e7c-b3e0-8b494181c4e0.json new file mode 100644 index 00000000..f0ed2dcf --- /dev/null +++ b/backend-node/data/mail-sent/8990ea86-3112-4e7c-b3e0-8b494181c4e0.json @@ -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" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/89a32ace-f39b-44fa-b614-c65d96548f92.json b/backend-node/data/mail-sent/89a32ace-f39b-44fa-b614-c65d96548f92.json new file mode 100644 index 00000000..4ac647c7 --- /dev/null +++ b/backend-node/data/mail-sent/89a32ace-f39b-44fa-b614-c65d96548f92.json @@ -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
\r\n






---------- 전달된 메시지 ----------


보낸 사람: \"기상청 API허브\"


날짜: 2025. 10. 13. 오후 4:26:45


제목: 기상청 API허브 회원가입 인증번호




undefined

\r\n
\r\n ", + "status": "success", + "messageId": "<9b36ce56-4ef1-cf0c-1f39-2c73bcb521da@wace.me>", + "accepted": [ + "zian9227@naver.com" + ], + "rejected": [] +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/99703f2c-740c-492e-a866-a04289a9b699.json b/backend-node/data/mail-sent/99703f2c-740c-492e-a866-a04289a9b699.json new file mode 100644 index 00000000..1c6dc41f --- /dev/null +++ b/backend-node/data/mail-sent/99703f2c-740c-492e-a866-a04289a9b699.json @@ -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" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e.json b/backend-node/data/mail-sent/9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e.json new file mode 100644 index 00000000..31bde67a --- /dev/null +++ b/backend-node/data/mail-sent/9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e.json @@ -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": [ + "\"이희진\" " + ], + "subject": "Re: ㅅㄷㄴㅅ", + "htmlContent": "\r\n
\r\n

배불르고 졸린데 커피먹으니깐 졸린건 괜찮아졋고 배불러서 물배찼당아아아아

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"이희진\"

\r\n

날짜: 2025. 10. 22. 오후 1:03:03

\r\n

제목: ㅅㄷㄴㅅ

\r\n
\r\n undefined\r\n
\r\n ", + "status": "success", + "messageId": "<0f215ba8-a1e4-8c5a-f43f-962f0717c161@wace.me>", + "accepted": [ + "zian9227@naver.com" + ], + "rejected": [], + "deletedAt": "2025-10-22T07:11:10.245Z" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/9d0b9fcf-cabf-4053-b6b6-6e110add22de.json b/backend-node/data/mail-sent/9d0b9fcf-cabf-4053-b6b6-6e110add22de.json new file mode 100644 index 00000000..2ace7d67 --- /dev/null +++ b/backend-node/data/mail-sent/9d0b9fcf-cabf-4053-b6b6-6e110add22de.json @@ -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

\n
\n

원본 메일:

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 22. 오후 1:32:34

\n

제목: ㅏㅣ

\n
\n

undefined

\n
\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" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/a638f7d0-ee31-47fa-9f72-de66ef31ea44.json b/backend-node/data/mail-sent/a638f7d0-ee31-47fa-9f72-de66ef31ea44.json new file mode 100644 index 00000000..5cf165c3 --- /dev/null +++ b/backend-node/data/mail-sent/a638f7d0-ee31-47fa-9f72-de66ef31ea44.json @@ -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
\r\n

ㄹㅇㄴㅁㄹㅇㄴㅁㅇㄹㅇㄴㅁ

\r\n
\r\n ", + "status": "success", + "messageId": "<5ea07d02-78bf-a655-8289-bcbd8eaf7741@wace.me>", + "accepted": [ + "zian9227@naver.com" + ], + "rejected": [] +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/b293e530-2b2d-4b8a-8081-d103fab5a13f.json b/backend-node/data/mail-sent/b293e530-2b2d-4b8a-8081-d103fab5a13f.json new file mode 100644 index 00000000..77d9053f --- /dev/null +++ b/backend-node/data/mail-sent/b293e530-2b2d-4b8a-8081-d103fab5a13f.json @@ -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

\n
\n

원본 메일:

\n

보낸사람: \"이희진\"

\n

날짜: 2025. 10. 13. 오전 10:40:30

\n

제목: 수신메일확인용

\n
\n undefined\n
\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" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/cf892a77-1998-4165-bb9d-b390451465b2.json b/backend-node/data/mail-sent/cf892a77-1998-4165-bb9d-b390451465b2.json new file mode 100644 index 00000000..426f81fb --- /dev/null +++ b/backend-node/data/mail-sent/cf892a77-1998-4165-bb9d-b390451465b2.json @@ -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보낸사람: \"이희진\" \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" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/e3501abc-cd31-4b20-bb02-3c7ddbe54eb8.json b/backend-node/data/mail-sent/e3501abc-cd31-4b20-bb02-3c7ddbe54eb8.json new file mode 100644 index 00000000..cf31f7dc --- /dev/null +++ b/backend-node/data/mail-sent/e3501abc-cd31-4b20-bb02-3c7ddbe54eb8.json @@ -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" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/e93848a8-6901-44c4-b4db-27c8d2aeb8dd.json b/backend-node/data/mail-sent/e93848a8-6901-44c4-b4db-27c8d2aeb8dd.json new file mode 100644 index 00000000..74c8212f --- /dev/null +++ b/backend-node/data/mail-sent/e93848a8-6901-44c4-b4db-27c8d2aeb8dd.json @@ -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": [ + "\"권은아\" " + ], + "subject": "Re: 매우 졸린 오후예요", + "htmlContent": "\r\n
\r\n

호홋 답장 기능을 구현했다죵
얼른 퇴근하고 싪네여

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"권은아\"

\r\n

날짜: 2025. 10. 22. 오후 1:10:37

\r\n

제목: 매우 졸린 오후예요

\r\n
\r\n undefined\r\n
\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": [] +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/eb92ed00-cc4f-4cc8-94c9-9bef312d16db.json b/backend-node/data/mail-sent/eb92ed00-cc4f-4cc8-94c9-9bef312d16db.json new file mode 100644 index 00000000..0c19dc0c --- /dev/null +++ b/backend-node/data/mail-sent/eb92ed00-cc4f-4cc8-94c9-9bef312d16db.json @@ -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" +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/fcea6149-a098-4212-aa00-baef0cc083d6.json b/backend-node/data/mail-sent/fcea6149-a098-4212-aa00-baef0cc083d6.json new file mode 100644 index 00000000..efd9a0c0 --- /dev/null +++ b/backend-node/data/mail-sent/fcea6149-a098-4212-aa00-baef0cc083d6.json @@ -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\" " + ], + "subject": "Re: 안녕하세여", + "htmlContent": "\r\n
\r\n

어떻게 가는지 궁금한데 이따가 화면 보여주세영

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"DHS\"

\r\n

날짜: 2025. 10. 22. 오후 1:09:49

\r\n

제목: 안녕하세여

\r\n
\r\n undefined\r\n
\r\n ", + "status": "success", + "messageId": "", + "accepted": [ + "ddhhss0603@gmail.com" + ], + "rejected": [] +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082.json b/backend-node/data/mail-sent/fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082.json new file mode 100644 index 00000000..073c20f0 --- /dev/null +++ b/backend-node/data/mail-sent/fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082.json @@ -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": [ + "\"이희진\" " + ], + "subject": "Re: ㅅㄷㄴㅅ", + "htmlContent": "\r\n
\r\n

ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㄴㅇㄹㄴㅇㄹ

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"이희진\"

\r\n

날짜: 2025. 10. 22. 오후 1:03:03

\r\n

제목: ㅅㄷㄴㅅ

\r\n
\r\n undefined\r\n
\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": "", + "accepted": [ + "zian9227@naver.com" + ], + "rejected": [], + "deletedAt": "2025-10-22T07:11:12.907Z" +} \ No newline at end of file diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 46d2fea5..81adfc5c 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -31,6 +31,8 @@ "nodemailer": "^6.10.1", "oracledb": "^6.9.0", "pg": "^8.16.3", + "quill": "^2.0.3", + "react-quill": "^2.0.0", "redis": "^4.6.10", "uuid": "^13.0.0", "winston": "^3.11.0" @@ -3433,6 +3435,21 @@ "dev": true, "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": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", @@ -4437,6 +4454,24 @@ "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": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -4610,6 +4645,15 @@ "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": { "version": "1.1.2", "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": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4988,6 +5052,23 @@ "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": { "version": "3.0.0", "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" } }, + "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -5554,6 +5652,12 @@ "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": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -5689,6 +5793,12 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "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": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5696,6 +5806,12 @@ "dev": true, "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": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -5997,6 +6113,15 @@ "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": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", @@ -6249,6 +6374,18 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -6563,6 +6700,22 @@ "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": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -6599,6 +6752,22 @@ "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -6701,6 +6870,24 @@ "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -7658,6 +7845,24 @@ "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": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -7670,6 +7875,13 @@ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", "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": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", @@ -8292,6 +8504,31 @@ "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": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -8436,6 +8673,12 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -8960,6 +9203,35 @@ ], "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": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -9003,6 +9275,67 @@ "dev": true, "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": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", @@ -9054,6 +9387,26 @@ "@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": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -9325,6 +9678,38 @@ "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": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index a6744ac6..bacd9fb3 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -45,6 +45,8 @@ "nodemailer": "^6.10.1", "oracledb": "^6.9.0", "pg": "^8.16.3", + "quill": "^2.0.3", + "react-quill": "^2.0.0", "redis": "^4.6.10", "uuid": "^13.0.0", "winston": "^3.11.0" diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index d3b366cb..979d191b 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -31,6 +31,7 @@ import layoutRoutes from "./routes/layoutRoutes"; import mailTemplateFileRoutes from "./routes/mailTemplateFileRoutes"; import mailAccountFileRoutes from "./routes/mailAccountFileRoutes"; import mailSendSimpleRoutes from "./routes/mailSendSimpleRoutes"; +import mailSentHistoryRoutes from "./routes/mailSentHistoryRoutes"; import mailReceiveBasicRoutes from "./routes/mailReceiveBasicRoutes"; import dataRoutes from "./routes/dataRoutes"; import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes"; @@ -186,6 +187,7 @@ app.use("/api/layouts", layoutRoutes); app.use("/api/mail/accounts", mailAccountFileRoutes); // 파일 기반 계정 app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿 app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송 +app.use("/api/mail/sent", mailSentHistoryRoutes); // 메일 발송 이력 app.use("/api/mail/receive", mailReceiveBasicRoutes); // 메일 수신 app.use("/api/screen", screenStandardRoutes); app.use("/api/data", dataRoutes); @@ -270,6 +272,28 @@ app.listen(PORT, HOST, async () => { } catch (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; diff --git a/backend-node/src/controllers/mailReceiveBasicController.ts b/backend-node/src/controllers/mailReceiveBasicController.ts index 7722840d..2de79185 100644 --- a/backend-node/src/controllers/mailReceiveBasicController.ts +++ b/backend-node/src/controllers/mailReceiveBasicController.ts @@ -18,11 +18,11 @@ export class MailReceiveBasicController { */ async getMailList(req: Request, res: Response) { try { - console.log('📬 메일 목록 조회 요청:', { - params: req.params, - path: req.path, - originalUrl: req.originalUrl - }); + // console.log('📬 메일 목록 조회 요청:', { + // params: req.params, + // path: req.path, + // originalUrl: req.originalUrl + // }); const { accountId } = req.params; const limit = parseInt(req.query.limit as string) || 50; @@ -49,11 +49,11 @@ export class MailReceiveBasicController { */ async getMailDetail(req: Request, res: Response) { try { - console.log('🔍 메일 상세 조회 요청:', { - params: req.params, - path: req.path, - originalUrl: req.originalUrl - }); + // console.log('🔍 메일 상세 조회 요청:', { + // params: req.params, + // path: req.path, + // originalUrl: req.originalUrl + // }); const { accountId, seqno } = req.params; const seqnoNumber = parseInt(seqno, 10); @@ -121,39 +121,39 @@ export class MailReceiveBasicController { */ async downloadAttachment(req: Request, res: Response) { try { - console.log('📎🎯 컨트롤러 downloadAttachment 진입'); + // console.log('📎🎯 컨트롤러 downloadAttachment 진입'); 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 indexNumber = parseInt(index, 10); if (isNaN(seqnoNumber) || isNaN(indexNumber)) { - console.log('❌ 유효하지 않은 파라미터'); + // console.log('❌ 유효하지 않은 파라미터'); return res.status(400).json({ success: false, message: '유효하지 않은 파라미터입니다.', }); } - console.log('📎 서비스 호출 시작...'); + // console.log('📎 서비스 호출 시작...'); const result = await this.mailReceiveService.downloadAttachment( accountId, seqnoNumber, indexNumber ); - console.log(`📎 서비스 호출 완료: result=${result ? '있음' : '없음'}`); + // console.log(`📎 서비스 호출 완료: result=${result ? '있음' : '없음'}`); if (!result) { - console.log('❌ 첨부파일을 찾을 수 없음'); + // console.log('❌ 첨부파일을 찾을 수 없음'); return res.status(404).json({ success: false, message: '첨부파일을 찾을 수 없습니다.', }); } - console.log(`📎 파일 다운로드 시작: ${result.filename}`); - console.log(`📎 파일 경로: ${result.filePath}`); + // console.log(`📎 파일 다운로드 시작: ${result.filename}`); + // console.log(`📎 파일 경로: ${result.filePath}`); // 파일 다운로드 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(); diff --git a/backend-node/src/controllers/mailSendSimpleController.ts b/backend-node/src/controllers/mailSendSimpleController.ts index de8610b7..4736e98c 100644 --- a/backend-node/src/controllers/mailSendSimpleController.ts +++ b/backend-node/src/controllers/mailSendSimpleController.ts @@ -7,14 +7,14 @@ export class MailSendSimpleController { */ async sendMail(req: Request, res: Response) { try { - console.log('📧 메일 발송 요청 수신:', { - accountId: req.body.accountId, - to: req.body.to, - cc: req.body.cc, - bcc: req.body.bcc, - subject: req.body.subject, - attachments: req.files ? (req.files as Express.Multer.File[]).length : 0, - }); + // console.log('📧 메일 발송 요청 수신:', { + // accountId: req.body.accountId, + // to: req.body.to, + // cc: req.body.cc, + // bcc: req.body.bcc, + // subject: req.body.subject, + // attachments: req.files ? (req.files as Express.Multer.File[]).length : 0, + // }); // FormData에서 JSON 문자열 파싱 const accountId = req.body.accountId; @@ -31,7 +31,7 @@ export class MailSendSimpleController { // 필수 파라미터 검증 if (!accountId || !to || !Array.isArray(to) || to.length === 0) { - console.log('❌ 필수 파라미터 누락'); + // console.log('❌ 필수 파라미터 누락'); return res.status(400).json({ success: false, message: '계정 ID와 수신자 이메일이 필요합니다.', @@ -63,9 +63,9 @@ export class MailSendSimpleController { if (req.body.fileNames) { try { parsedFileNames = JSON.parse(req.body.fileNames); - console.log('📎 프론트엔드에서 받은 파일명들:', parsedFileNames); + // console.log('📎 프론트엔드에서 받은 파일명들:', parsedFileNames); } catch (e) { - console.warn('파일명 파싱 실패, multer originalname 사용'); + // console.warn('파일명 파싱 실패, multer originalname 사용'); } } @@ -83,10 +83,10 @@ export class MailSendSimpleController { }); }); - console.log('📎 최종 첨부파일 정보:', attachments.map(a => ({ - filename: a.filename, - path: a.path.split('/').pop() - }))); + // console.log('📎 최종 첨부파일 정보:', attachments.map(a => ({ + // filename: a.filename, + // 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 연결 테스트 */ diff --git a/backend-node/src/controllers/mailSentHistoryController.ts b/backend-node/src/controllers/mailSentHistoryController.ts index 129d72a7..5451862f 100644 --- a/backend-node/src/controllers/mailSentHistoryController.ts +++ b/backend-node/src/controllers/mailSentHistoryController.ts @@ -11,12 +11,14 @@ export class MailSentHistoryController { page: req.query.page ? parseInt(req.query.page as string) : undefined, limit: req.query.limit ? parseInt(req.query.limit as string) : undefined, searchTerm: req.query.searchTerm as string | undefined, - status: req.query.status as 'success' | 'failed' | 'all' | undefined, + status: req.query.status as 'success' | 'failed' | 'draft' | 'all' | undefined, accountId: req.query.accountId as string | undefined, startDate: req.query.startDate as string | undefined, endDate: req.query.endDate as string | undefined, - sortBy: req.query.sortBy as 'sentAt' | 'subject' | undefined, + sortBy: req.query.sortBy as 'sentAt' | 'subject' | 'updatedAt' | 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); @@ -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(); diff --git a/backend-node/src/routes/mailReceiveBasicRoutes.ts b/backend-node/src/routes/mailReceiveBasicRoutes.ts index d40c4629..60676ef6 100644 --- a/backend-node/src/routes/mailReceiveBasicRoutes.ts +++ b/backend-node/src/routes/mailReceiveBasicRoutes.ts @@ -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.delete('/:accountId/:seqno', (req, res) => controller.deleteMail(req, res)); + // 메일 상세 조회 - /:accountId보다 먼저 정의해야 함 router.get('/:accountId/:seqno', (req, res) => controller.getMailDetail(req, res)); diff --git a/backend-node/src/routes/mailSendSimpleRoutes.ts b/backend-node/src/routes/mailSendSimpleRoutes.ts index f354957c..12c1ccff 100644 --- a/backend-node/src/routes/mailSendSimpleRoutes.ts +++ b/backend-node/src/routes/mailSendSimpleRoutes.ts @@ -15,6 +15,9 @@ router.post( (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 연결 테스트 router.post('/test-connection', (req, res) => mailSendSimpleController.testConnection(req, res)); diff --git a/backend-node/src/routes/mailSentHistoryRoutes.ts b/backend-node/src/routes/mailSentHistoryRoutes.ts index 2f4c6f98..5863eed9 100644 --- a/backend-node/src/routes/mailSentHistoryRoutes.ts +++ b/backend-node/src/routes/mailSentHistoryRoutes.ts @@ -7,16 +7,37 @@ const router = Router(); // 모든 라우트에 인증 미들웨어 적용 router.use(authenticateToken); +// GET /api/mail/sent/statistics - 통계 조회 (⚠️ 반드시 /:id 보다 먼저 정의) +router.get('/statistics', (req, res) => mailSentHistoryController.getStatistics(req, res)); + // GET /api/mail/sent - 발송 이력 목록 조회 router.get('/', (req, res) => mailSentHistoryController.getList(req, res)); -// GET /api/mail/sent/statistics - 통계 조회 -router.get('/statistics', (req, res) => mailSentHistoryController.getStatistics(req, res)); +// POST /api/mail/sent/draft - 임시 저장 (Draft) +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 - 특정 발송 이력 상세 조회 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)); export default router; diff --git a/backend-node/src/services/mailReceiveBasicService.ts b/backend-node/src/services/mailReceiveBasicService.ts index d5e3a78f..6bec4d93 100644 --- a/backend-node/src/services/mailReceiveBasicService.ts +++ b/backend-node/src/services/mailReceiveBasicService.ts @@ -88,6 +88,9 @@ export class MailReceiveBasicService { port: config.port, tls: config.tls, tlsOptions: { rejectUnauthorized: false }, + authTimeout: 30000, // 인증 타임아웃 30초 + connTimeout: 30000, // 연결 타임아웃 30초 + keepalive: true, }); } @@ -116,7 +119,7 @@ export class MailReceiveBasicService { 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) => { const imap = this.createImapConnection(imapConfig); @@ -130,7 +133,7 @@ export class MailReceiveBasicService { }, 30000); imap.once("ready", () => { - // console.log('✅ IMAP 연결 성공! INBOX 열기 시도...'); + // // console.log('✅ IMAP 연결 성공! INBOX 열기 시도...'); clearTimeout(timeout); imap.openBox("INBOX", true, (err: any, box: any) => { @@ -140,10 +143,10 @@ export class MailReceiveBasicService { return reject(err); } - // console.log(`📬 INBOX 열림 - 전체 메일 수: ${box.messages.total}`); + // // console.log(`📬 INBOX 열림 - 전체 메일 수: ${box.messages.total}`); const totalMessages = box.messages.total; if (totalMessages === 0) { - // console.log('📭 메일함이 비어있습니다'); + // // console.log('📭 메일함이 비어있습니다'); imap.end(); return resolve([]); } @@ -152,19 +155,19 @@ export class MailReceiveBasicService { const start = Math.max(1, totalMessages - limit + 1); const end = totalMessages; - // console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`); + // // console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`); const fetch = imap.seq.fetch(`${start}:${end}`, { bodies: ["HEADER", "TEXT"], struct: true, }); - // console.log(`📦 fetch 객체 생성 완료`); + // // console.log(`📦 fetch 객체 생성 완료`); let processedCount = 0; const totalToProcess = end - start + 1; fetch.on("message", (msg: any, seqno: any) => { - // console.log(`📬 메일 #${seqno} 처리 시작`); + // // console.log(`📬 메일 #${seqno} 처리 시작`); let header: string = ""; let body: string = ""; let attributes: any = null; @@ -222,7 +225,7 @@ export class MailReceiveBasicService { }; mails.push(mail); - // console.log(`✓ 메일 #${seqno} 파싱 완료 (${mails.length}/${totalToProcess})`); + // // console.log(`✓ 메일 #${seqno} 파싱 완료 (${mails.length}/${totalToProcess})`); processedCount++; } catch (parseError) { // console.error(`메일 #${seqno} 파싱 오류:`, parseError); @@ -240,18 +243,18 @@ export class MailReceiveBasicService { }); fetch.once("end", () => { - // console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`); + // // console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`); // 모든 메일 처리가 완료될 때까지 대기 const checkComplete = setInterval(() => { - // console.log(`⏳ 대기 중 - 처리됨: ${processedCount}/${totalToProcess}, 메일: ${mails.length}개`); + // // console.log(`⏳ 대기 중 - 처리됨: ${processedCount}/${totalToProcess}, 메일: ${mails.length}개`); if (processedCount >= totalToProcess) { clearInterval(checkComplete); - // console.log(`✅ 메일 가져오기 완료 - 총 ${mails.length}개`); + // // console.log(`✅ 메일 가져오기 완료 - 총 ${mails.length}개`); imap.end(); // 최신 메일이 위로 오도록 정렬 mails.sort((a, b) => b.date.getTime() - a.date.getTime()); - // console.log(`📤 메일 목록 반환: ${mails.length}개`); + // // console.log(`📤 메일 목록 반환: ${mails.length}개`); resolve(mails); } }, 100); @@ -259,7 +262,7 @@ export class MailReceiveBasicService { // 최대 10초 대기 setTimeout(() => { clearInterval(checkComplete); - // console.log(`⚠️ 타임아웃 - 부분 반환: ${mails.length}/${totalToProcess}개`); + // // console.log(`⚠️ 타임아웃 - 부분 반환: ${mails.length}/${totalToProcess}개`); imap.end(); mails.sort((a, b) => b.date.getTime() - a.date.getTime()); resolve(mails); @@ -275,10 +278,10 @@ export class MailReceiveBasicService { }); imap.once("end", () => { - // console.log('🔌 IMAP 연결 종료'); + // // console.log('🔌 IMAP 연결 종료'); }); - // console.log('🔗 IMAP.connect() 호출...'); + // // console.log('🔗 IMAP.connect() 호출...'); imap.connect(); }); } @@ -329,9 +332,9 @@ export class MailReceiveBasicService { return reject(err); } - console.log( - `📬 INBOX 정보 - 전체 메일: ${box.messages.total}, 요청한 seqno: ${seqno}` - ); + // console.log( + // `📬 INBOX 정보 - 전체 메일: ${box.messages.total}, 요청한 seqno: ${seqno}` + // ); if (seqno > box.messages.total || seqno < 1) { console.error( @@ -350,21 +353,21 @@ export class MailReceiveBasicService { let parsingComplete = false; fetch.on("message", (msg: any, seqnum: any) => { - console.log(`📨 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`); + // console.log(`📨 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`); msg.on("body", (stream: any, info: any) => { - console.log(`📝 메일 본문 스트림 시작 - which: ${info.which}`); + // console.log(`📝 메일 본문 스트림 시작 - which: ${info.which}`); let buffer = ""; stream.on("data", (chunk: any) => { buffer += chunk.toString("utf8"); }); stream.once("end", async () => { - console.log( - `✅ 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}` - ); + // console.log( + // `✅ 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}` + // ); try { const parsed = await simpleParser(buffer); - console.log(`✅ 메일 파싱 완료 - 제목: ${parsed.subject}`); + // console.log(`✅ 메일 파싱 완료 - 제목: ${parsed.subject}`); const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] @@ -412,7 +415,7 @@ export class MailReceiveBasicService { // msg 전체가 처리되었을 때 이벤트 msg.once("end", () => { - console.log(`📮 메일 메시지 처리 완료 - seqnum: ${seqnum}`); + // console.log(`📮 메일 메시지 처리 완료 - seqnum: ${seqnum}`); }); }); @@ -423,15 +426,15 @@ export class MailReceiveBasicService { }); fetch.once("end", () => { - console.log(`🏁 Fetch 종료 - parsingComplete: ${parsingComplete}`); + // console.log(`🏁 Fetch 종료 - parsingComplete: ${parsingComplete}`); // 비동기 파싱이 완료될 때까지 대기 const waitForParsing = setInterval(() => { if (parsingComplete) { clearInterval(waitForParsing); - console.log( - `✅ 파싱 완료 대기 종료 - mailDetail이 ${mailDetail ? "존재함" : "null"}` - ); + // console.log( + // `✅ 파싱 완료 대기 종료 - mailDetail이 ${mailDetail ? "존재함" : "null"}` + // ); imap.end(); resolve(mailDetail); } @@ -474,29 +477,47 @@ export class MailReceiveBasicService { const decryptedPassword = encryptionService.decrypt(account.smtpPassword); const accountAny = account as any; + const imapPort = accountAny.imapPort || this.inferImapPort(account.smtpPort); + const imapConfig: ImapConfig = { user: account.email, password: decryptedPassword, host: accountAny.imapHost || account.smtpHost, - port: this.inferImapPort(account.smtpPort, accountAny.imapPort), - tls: true, + port: imapPort, + tls: imapPort === 993, // 993 포트면 TLS 사용 }; return new Promise((resolve, reject) => { const imap = this.createImapConnection(imapConfig); + // 타임아웃 설정 + const timeout = setTimeout(() => { + console.error('❌ IMAP 읽음 표시 타임아웃 (30초)'); + imap.end(); + reject(new Error("IMAP 연결 타임아웃")); + }, 30000); + imap.once("ready", () => { + clearTimeout(timeout); + // console.log(`🔗 IMAP 연결 성공 - 읽음 표시 시작 (seqno=${seqno})`); + + // false로 변경: 쓰기 가능 모드로 INBOX 열기 imap.openBox("INBOX", false, (err: any, box: any) => { if (err) { + console.error('❌ INBOX 열기 실패:', err); imap.end(); return reject(err); } + // console.log(`📬 INBOX 열림 (쓰기 가능 모드)`); + imap.seq.addFlags(seqno, ["\\Seen"], (flagErr: any) => { imap.end(); if (flagErr) { + console.error("❌ 읽음 플래그 설정 실패:", flagErr); reject(flagErr); } else { + // console.log("✅ 읽음 플래그 설정 성공 - seqno:", seqno); resolve({ success: true, message: "메일을 읽음으로 표시했습니다.", @@ -507,9 +528,16 @@ export class MailReceiveBasicService { }); imap.once("error", (imapErr: any) => { + clearTimeout(timeout); + console.error('❌ IMAP 에러:', imapErr); reject(imapErr); }); + imap.once("end", () => { + clearTimeout(timeout); + }); + + // console.log(`🔌 IMAP 연결 시도 중... (host=${imapConfig.host}, port=${imapConfig.port})`); imap.connect(); }); } @@ -528,7 +556,7 @@ export class MailReceiveBasicService { // 비밀번호 복호화 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 imapConfig: ImapConfig = { @@ -538,7 +566,7 @@ export class MailReceiveBasicService { port: this.inferImapPort(account.smtpPort, accountAny.imapPort), 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) => { const imap = this.createImapConnection(imapConfig); @@ -664,32 +692,32 @@ export class MailReceiveBasicService { let parsingComplete = false; fetch.on("message", (msg: any, seqnum: any) => { - console.log(`📎 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`); + // console.log(`📎 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`); msg.on("body", (stream: any, info: any) => { - console.log(`📎 메일 본문 스트림 시작`); + // console.log(`📎 메일 본문 스트림 시작`); let buffer = ""; stream.on("data", (chunk: any) => { buffer += chunk.toString("utf8"); }); stream.once("end", async () => { - console.log( - `📎 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}` - ); + // console.log( + // `📎 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}` + // ); try { const parsed = await simpleParser(buffer); - console.log( - `📎 파싱 완료 - 첨부파일 개수: ${parsed.attachments?.length || 0}` - ); + // console.log( + // `📎 파싱 완료 - 첨부파일 개수: ${parsed.attachments?.length || 0}` + // ); if ( parsed.attachments && parsed.attachments[attachmentIndex] ) { const attachment = parsed.attachments[attachmentIndex]; - console.log( - `📎 첨부파일 발견 (index ${attachmentIndex}): ${attachment.filename}` - ); + // console.log( + // `📎 첨부파일 발견 (index ${attachmentIndex}): ${attachment.filename}` + // ); // 안전한 파일명 생성 const safeFilename = this.sanitizeFilename( @@ -701,7 +729,7 @@ export class MailReceiveBasicService { // 파일 저장 await fs.writeFile(filePath, attachment.content); - console.log(`📎 파일 저장 완료: ${filePath}`); + // console.log(`📎 파일 저장 완료: ${filePath}`); attachmentResult = { filePath, @@ -711,9 +739,9 @@ export class MailReceiveBasicService { }; parsingComplete = true; } else { - console.log( - `❌ 첨부파일 index ${attachmentIndex}를 찾을 수 없음 (총 ${parsed.attachments?.length || 0}개)` - ); + // console.log( + // `❌ 첨부파일 index ${attachmentIndex}를 찾을 수 없음 (총 ${parsed.attachments?.length || 0}개)` + // ); parsingComplete = true; } } catch (parseError) { @@ -731,14 +759,14 @@ export class MailReceiveBasicService { }); fetch.once("end", () => { - console.log('📎 fetch.once("end") 호출됨 - 파싱 완료 대기 시작...'); + // console.log('📎 fetch.once("end") 호출됨 - 파싱 완료 대기 시작...'); // 파싱 완료를 기다림 (최대 5초) const checkComplete = setInterval(() => { if (parsingComplete) { - console.log( - `✅ 파싱 완료 확인 - attachmentResult: ${attachmentResult ? "있음" : "없음"}` - ); + // console.log( + // `✅ 파싱 완료 확인 - attachmentResult: ${attachmentResult ? "있음" : "없음"}` + // ); clearInterval(checkComplete); imap.end(); resolve(attachmentResult); @@ -747,9 +775,9 @@ export class MailReceiveBasicService { setTimeout(() => { clearInterval(checkComplete); - console.log( - `⚠️ 타임아웃 - attachmentResult: ${attachmentResult ? "있음" : "없음"}` - ); + // console.log( + // `⚠️ 타임아웃 - attachmentResult: ${attachmentResult ? "있음" : "없음"}` + // ); imap.end(); resolve(attachmentResult); }, 5000); @@ -774,4 +802,96 @@ export class MailReceiveBasicService { .replace(/_{2,}/g, "_") .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(); diff --git a/backend-node/src/services/mailSendSimpleService.ts b/backend-node/src/services/mailSendSimpleService.ts index 188e68c8..b4dce503 100644 --- a/backend-node/src/services/mailSendSimpleService.ts +++ b/backend-node/src/services/mailSendSimpleService.ts @@ -34,6 +34,29 @@ export interface SendMailResult { error?: string; } +export interface BulkSendRequest { + accountId: string; + templateId?: string; // 템플릿 ID (선택) + subject: string; + customHtml?: string; // 직접 작성한 HTML (선택) + recipients: Array<{ + email: string; + variables?: Record; // 템플릿 사용 시에만 필요 + }>; +} + +export interface BulkSendResult { + total: number; + success: number; + failed: number; + results: Array<{ + email: string; + success: boolean; + messageId?: string; + error?: string; + }>; +} + class MailSendSimpleService { /** * 단일 메일 발송 또는 소규모 발송 @@ -63,7 +86,7 @@ class MailSendSimpleService { // 🎯 수정된 컴포넌트가 있으면 덮어쓰기 if (request.modifiedTemplateComponents && request.modifiedTemplateComponents.length > 0) { - console.log('✏️ 수정된 템플릿 컴포넌트 사용:', request.modifiedTemplateComponents.length); + // console.log('✏️ 수정된 템플릿 컴포넌트 사용:', request.modifiedTemplateComponents.length); template.components = request.modifiedTemplateComponents; } @@ -84,15 +107,15 @@ class MailSendSimpleService { // 4. 비밀번호 복호화 const decryptedPassword = encryptionService.decrypt(account.smtpPassword); - // console.log('🔐 비밀번호 복호화 완료'); - // console.log('🔐 암호화된 비밀번호 (일부):', account.smtpPassword.substring(0, 30) + '...'); - // console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length); + // // console.log('🔐 비밀번호 복호화 완료'); + // // console.log('🔐 암호화된 비밀번호 (일부):', account.smtpPassword.substring(0, 30) + '...'); + // // console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length); // 5. SMTP 연결 생성 // 포트 465는 SSL/TLS를 사용해야 함 const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false); - // console.log('📧 SMTP 연결 설정:', { + // // console.log('📧 SMTP 연결 설정:', { // host: account.smtpHost, // port: account.smtpPort, // secure: isSecure, @@ -112,7 +135,7 @@ class MailSendSimpleService { greetingTimeout: 30000, }); - console.log('📧 메일 발송 시도 중...'); + // console.log('📧 메일 발송 시도 중...'); // 6. 메일 발송 (CC, BCC, 첨부파일 지원) const mailOptions: any = { @@ -125,13 +148,13 @@ class MailSendSimpleService { // 참조(CC) 추가 if (request.cc && request.cc.length > 0) { mailOptions.cc = request.cc.join(', '); - // console.log('📧 참조(CC):', request.cc); + // // console.log('📧 참조(CC):', request.cc); } // 숨은참조(BCC) 추가 if (request.bcc && request.bcc.length > 0) { 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('📎 첨부파일 (인코딩):', mailOptions.attachments.map((a: any) => a.filename)); + // console.log('📎 첨부파일 (원본):', request.attachments.map((a: any) => a.filename.replace(/^\d+-\d+_/, ''))); + // console.log('📎 첨부파일 (인코딩):', mailOptions.attachments.map((a: any) => a.filename)); } const info = await transporter.sendMail(mailOptions); - console.log('✅ 메일 발송 성공:', { - messageId: info.messageId, - accepted: info.accepted, - rejected: info.rejected, - }); + // console.log('✅ 메일 발송 성공:', { + // messageId: info.messageId, + // accepted: info.accepted, + // rejected: info.rejected, + // }); // 발송 이력 저장 (성공) try { @@ -402,6 +425,73 @@ class MailSendSimpleService { } } + /** + * 대량 메일 발송 (배치 처리) + */ + async sendBulkMail(request: BulkSendRequest): Promise { + 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 연결 테스트 */ @@ -414,13 +504,13 @@ class MailSendSimpleService { // 비밀번호 복호화 const decryptedPassword = encryptionService.decrypt(account.smtpPassword); - // console.log('🔐 테스트용 비밀번호 복호화 완료'); - // console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length); + // // console.log('🔐 테스트용 비밀번호 복호화 완료'); + // // console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length); // 포트 465는 SSL/TLS를 사용해야 함 const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false); - // console.log('🧪 SMTP 연결 테스트 시작:', { + // // console.log('🧪 SMTP 연결 테스트 시작:', { // host: account.smtpHost, // port: account.smtpPort, // secure: isSecure, @@ -443,7 +533,7 @@ class MailSendSimpleService { // 연결 테스트 await transporter.verify(); - console.log('✅ SMTP 연결 테스트 성공'); + // console.log('✅ SMTP 연결 테스트 성공'); return { success: true, message: 'SMTP 연결이 성공했습니다.' }; } catch (error) { const err = error as Error; diff --git a/backend-node/src/services/mailSentHistoryService.ts b/backend-node/src/services/mailSentHistoryService.ts index c7828888..f0a80265 100644 --- a/backend-node/src/services/mailSentHistoryService.ts +++ b/backend-node/src/services/mailSentHistoryService.ts @@ -53,7 +53,7 @@ class MailSentHistoryService { mode: 0o644, }); - console.log("발송 이력 저장:", history.id); + // console.log("발송 이력 저장:", history.id); } catch (error) { console.error("발송 이력 저장 실패:", error); // 파일 저장 실패해도 history 객체는 반환 (메일 발송은 성공했으므로) @@ -86,7 +86,7 @@ class MailSentHistoryService { try { // 디렉토리가 없으면 빈 배열 반환 if (!fs.existsSync(SENT_MAIL_DIR)) { - console.warn("메일 발송 이력 디렉토리가 없습니다:", SENT_MAIL_DIR); + // console.warn("메일 발송 이력 디렉토리가 없습니다:", SENT_MAIL_DIR); return { items: [], total: 0, @@ -124,6 +124,13 @@ class MailSentHistoryService { // 필터링 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") { filtered = filtered.filter((h) => h.status === status); @@ -209,9 +216,151 @@ class MailSentHistoryService { } /** - * 발송 이력 삭제 + * 임시 저장 (Draft) + */ + async saveDraft( + data: Partial & { accountId: string } + ): Promise { + // 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 + ): Promise { + 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 { + 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 { + 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 { const filePath = path.join(SENT_MAIL_DIR, `${id}.json`); if (!fs.existsSync(filePath)) { @@ -220,14 +369,57 @@ class MailSentHistoryService { try { fs.unlinkSync(filePath); - console.log("🗑️ 발송 이력 삭제:", id); + // console.log("🗑️ 메일 영구 삭제:", id); return true; } catch (error) { - console.error("발송 이력 삭제 실패:", error); + console.error("메일 영구 삭제 실패:", error); return false; } } + /** + * 30일 이상 지난 삭제된 메일 자동 영구 삭제 + */ + async cleanupOldDeletedMails(): Promise { + 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; + } + /** * 통계 조회 */ diff --git a/backend-node/src/services/mailTemplateFileService.ts b/backend-node/src/services/mailTemplateFileService.ts index e1a878b9..adb72fff 100644 --- a/backend-node/src/services/mailTemplateFileService.ts +++ b/backend-node/src/services/mailTemplateFileService.ts @@ -50,11 +50,25 @@ class MailTemplateFileService { process.env.NODE_ENV === "production" ? "/app/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() { try { @@ -75,8 +89,6 @@ class MailTemplateFileService { * 모든 템플릿 목록 조회 */ async getAllTemplates(): Promise { - await this.ensureDirectoryExists(); - try { const files = await fs.readdir(this.templatesDir); const jsonFiles = files.filter((f) => f.endsWith(".json")); @@ -97,6 +109,7 @@ class MailTemplateFileService { new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() ); } catch (error) { + // 디렉토리가 없거나 읽기 실패 시 빈 배열 반환 return []; } } @@ -160,7 +173,7 @@ class MailTemplateFileService { updatedAt: new Date().toISOString(), }; - // console.log(`📝 템플릿 저장 시도: ${id}, 크기: ${JSON.stringify(updated).length} bytes`); + // // console.log(`📝 템플릿 저장 시도: ${id}, 크기: ${JSON.stringify(updated).length} bytes`); await fs.writeFile( this.getTemplatePath(id), @@ -168,7 +181,7 @@ class MailTemplateFileService { "utf-8" ); - // console.log(`✅ 템플릿 저장 성공: ${id}`); + // // console.log(`✅ 템플릿 저장 성공: ${id}`); return updated; } catch (error) { // console.error(`❌ 템플릿 저장 실패: ${id}`, error); diff --git a/backend-node/src/types/mailSentHistory.ts b/backend-node/src/types/mailSentHistory.ts index 1366acf4..856cbd4f 100644 --- a/backend-node/src/types/mailSentHistory.ts +++ b/backend-node/src/types/mailSentHistory.ts @@ -24,13 +24,18 @@ export interface SentMailHistory { // 발송 정보 sentAt: string; // 발송 시간 (ISO 8601) - status: 'success' | 'failed'; // 발송 상태 + status: 'success' | 'failed' | 'draft'; // 발송 상태 (draft 추가) messageId?: string; // SMTP 메시지 ID (성공 시) errorMessage?: string; // 오류 메시지 (실패 시) // 발송 결과 accepted?: string[]; // 수락된 이메일 주소 rejected?: string[]; // 거부된 이메일 주소 + + // 임시 저장 및 삭제 + isDraft?: boolean; // 임시 저장 여부 + deletedAt?: string; // 삭제 시간 (ISO 8601) + updatedAt?: string; // 수정 시간 (ISO 8601) } export interface AttachmentInfo { @@ -45,12 +50,14 @@ export interface SentMailListQuery { page?: number; // 페이지 번호 (1부터 시작) limit?: number; // 페이지당 항목 수 searchTerm?: string; // 검색어 (제목, 받는사람) - status?: 'success' | 'failed' | 'all'; // 필터: 상태 + status?: 'success' | 'failed' | 'draft' | 'all'; // 필터: 상태 (draft 추가) accountId?: string; // 필터: 발송 계정 startDate?: string; // 필터: 시작 날짜 (ISO 8601) endDate?: string; // 필터: 종료 날짜 (ISO 8601) - sortBy?: 'sentAt' | 'subject'; // 정렬 기준 + sortBy?: 'sentAt' | 'subject' | 'updatedAt'; // 정렬 기준 (updatedAt 추가) sortOrder?: 'asc' | 'desc'; // 정렬 순서 + includeDeleted?: boolean; // 삭제된 메일 포함 여부 + onlyDeleted?: boolean; // 삭제된 메일만 조회 } export interface SentMailListResponse { diff --git a/frontend/app/(main)/admin/mail/accounts/page.tsx b/frontend/app/(main)/admin/mail/accounts/page.tsx index 482452a8..3e716d25 100644 --- a/frontend/app/(main)/admin/mail/accounts/page.tsx +++ b/frontend/app/(main)/admin/mail/accounts/page.tsx @@ -38,11 +38,11 @@ export default function MailAccountsPage() { if (Array.isArray(data)) { setAccounts(data); } else { - console.error('API 응답이 배열이 아닙니다:', data); + // console.error('API 응답이 배열이 아닙니다:', data); setAccounts([]); } } catch (error) { - console.error('계정 로드 실패:', error); + // console.error('계정 로드 실패:', error); setAccounts([]); // 에러 시 빈 배열로 설정 // alert('계정 목록을 불러오는데 실패했습니다.'); } finally { @@ -93,7 +93,7 @@ export default function MailAccountsPage() { await loadAccounts(); alert('계정이 삭제되었습니다.'); } catch (error) { - console.error('계정 삭제 실패:', error); + // console.error('계정 삭제 실패:', error); alert('계정 삭제에 실패했습니다.'); } }; @@ -104,7 +104,7 @@ export default function MailAccountsPage() { await updateMailAccount(account.id, { status: newStatus }); await loadAccounts(); } catch (error) { - console.error('상태 변경 실패:', error); + // console.error('상태 변경 실패:', error); alert('상태 변경에 실패했습니다.'); } }; @@ -120,7 +120,7 @@ export default function MailAccountsPage() { alert(`❌ SMTP 연결 실패\n\n${result.message || '연결에 실패했습니다.'}`); } } catch (error: any) { - console.error('연결 테스트 실패:', error); + // console.error('연결 테스트 실패:', error); alert(`❌ SMTP 연결 테스트 실패\n\n${error.message || '알 수 없는 오류가 발생했습니다.'}`); } finally { setLoading(false); diff --git a/frontend/app/(main)/admin/mail/bulk-send/page.tsx b/frontend/app/(main)/admin/mail/bulk-send/page.tsx new file mode 100644 index 00000000..e6a85942 --- /dev/null +++ b/frontend/app/(main)/admin/mail/bulk-send/page.tsx @@ -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; +} + +export default function BulkSendPage() { + const router = useRouter(); + const { toast } = useToast(); + + const [accounts, setAccounts] = useState([]); + const [templates, setTemplates] = useState([]); + const [selectedAccountId, setSelectedAccountId] = useState(""); + const [selectedTemplateId, setSelectedTemplateId] = useState(""); + const [useTemplate, setUseTemplate] = useState(true); // 템플릿 사용 여부 + const [customHtml, setCustomHtml] = useState(""); // 직접 작성한 HTML + const [subject, setSubject] = useState(""); + const [recipients, setRecipients] = useState([]); + const [csvFile, setCsvFile] = useState(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) => { + 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 = {}; + + 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 ( +
+
+ {/* 헤더 */} +
+
+
+ +
+
+

대량 메일 발송

+

CSV 파일로 여러 수신자에게 메일을 발송하세요

+
+
+ + + +
+ +
+ {/* 왼쪽: 설정 */} +
+ {/* 계정 선택 */} + + + 발송 설정 + + +
+ + +
+ +
+ + +
+ + {useTemplate ? ( +
+ + +
+ ) : ( +
+ +