From 479b0ba3edf93c765d122c3dd0af8dc8418f9ad3 Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 22 Oct 2025 16:06:04 +0900 Subject: [PATCH] =?UTF-8?q?ui=20=EA=B3=A0=EC=B9=98=EA=B8=B0=20=EC=A0=84=20?= =?UTF-8?q?=EC=84=B8=EC=9D=B4=EB=B8=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../12b583c9-a6b2-4c7f-8340-fd0e700aa32e.json | 19 + .../375f2326-ca86-468a-bfc3-2d4c3825577b.json | 18 + .../386e334a-df76-440c-ae8a-9bf06982fdc8.json | 15 + .../3d411dc4-69a6-4236-b878-9693dff881be.json | 17 + .../3e30a264-8431-44c7-96ef-eed551e66a11.json | 15 + .../5bfb2acd-023a-4865-a738-2900179db5fb.json | 15 + .../683c1323-1895-403a-bb9a-4e111a8909f6.json | 17 + .../7bed27d5-dae4-4ba8-85d0-c474c4fb907a.json | 15 + .../84ee9619-49ff-4f61-a7fa-0bb0b0b7199a.json | 18 + .../8990ea86-3112-4e7c-b3e0-8b494181c4e0.json | 12 + .../89a32ace-f39b-44fa-b614-c65d96548f92.json | 18 + .../99703f2c-740c-492e-a866-a04289a9b699.json | 13 + .../9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e.json | 18 + .../9d0b9fcf-cabf-4053-b6b6-6e110add22de.json | 17 + .../b293e530-2b2d-4b8a-8081-d103fab5a13f.json | 17 + .../e3501abc-cd31-4b20-bb02-3c7ddbe54eb8.json | 12 + .../e93848a8-6901-44c4-b4db-27c8d2aeb8dd.json | 27 + .../eb92ed00-cc4f-4cc8-94c9-9bef312d16db.json | 16 + .../fcea6149-a098-4212-aa00-baef0cc083d6.json | 18 + .../fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082.json | 27 + backend-node/package-lock.json | 385 ++++++++++++ backend-node/package.json | 2 + backend-node/src/app.ts | 22 + .../controllers/mailReceiveBasicController.ts | 28 + .../controllers/mailSendSimpleController.ts | 48 ++ .../controllers/mailSentHistoryController.ts | 255 +++++++- .../src/routes/mailReceiveBasicRoutes.ts | 3 + .../src/routes/mailSendSimpleRoutes.ts | 3 + .../src/routes/mailSentHistoryRoutes.ts | 23 +- .../src/services/mailReceiveBasicService.ts | 122 +++- .../src/services/mailSendSimpleService.ts | 88 +++ .../src/services/mailSentHistoryService.ts | 198 ++++++- .../src/services/mailTemplateFileService.ts | 21 +- backend-node/src/types/mailSentHistory.ts | 13 +- .../app/(main)/admin/mail/bulk-send/page.tsx | 481 +++++++++++++++ .../app/(main)/admin/mail/dashboard/page.tsx | 53 +- .../app/(main)/admin/mail/drafts/page.tsx | 201 +++++++ .../app/(main)/admin/mail/receive/page.tsx | 557 ++++++++++++++---- frontend/app/(main)/admin/mail/send/page.tsx | 400 +++++++++++-- frontend/app/(main)/admin/mail/trash/page.tsx | 192 ++++++ frontend/components/mail/MailDetailModal.tsx | 83 ++- .../components/mail/MailNotifications.tsx | 381 ++++++++++++ frontend/lib/api/mail.ts | 153 ++++- 43 files changed, 3828 insertions(+), 228 deletions(-) create mode 100644 backend-node/data/mail-sent/12b583c9-a6b2-4c7f-8340-fd0e700aa32e.json create mode 100644 backend-node/data/mail-sent/375f2326-ca86-468a-bfc3-2d4c3825577b.json create mode 100644 backend-node/data/mail-sent/386e334a-df76-440c-ae8a-9bf06982fdc8.json create mode 100644 backend-node/data/mail-sent/3d411dc4-69a6-4236-b878-9693dff881be.json create mode 100644 backend-node/data/mail-sent/3e30a264-8431-44c7-96ef-eed551e66a11.json create mode 100644 backend-node/data/mail-sent/5bfb2acd-023a-4865-a738-2900179db5fb.json create mode 100644 backend-node/data/mail-sent/683c1323-1895-403a-bb9a-4e111a8909f6.json create mode 100644 backend-node/data/mail-sent/7bed27d5-dae4-4ba8-85d0-c474c4fb907a.json create mode 100644 backend-node/data/mail-sent/84ee9619-49ff-4f61-a7fa-0bb0b0b7199a.json create mode 100644 backend-node/data/mail-sent/8990ea86-3112-4e7c-b3e0-8b494181c4e0.json create mode 100644 backend-node/data/mail-sent/89a32ace-f39b-44fa-b614-c65d96548f92.json create mode 100644 backend-node/data/mail-sent/99703f2c-740c-492e-a866-a04289a9b699.json create mode 100644 backend-node/data/mail-sent/9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e.json create mode 100644 backend-node/data/mail-sent/9d0b9fcf-cabf-4053-b6b6-6e110add22de.json create mode 100644 backend-node/data/mail-sent/b293e530-2b2d-4b8a-8081-d103fab5a13f.json create mode 100644 backend-node/data/mail-sent/e3501abc-cd31-4b20-bb02-3c7ddbe54eb8.json create mode 100644 backend-node/data/mail-sent/e93848a8-6901-44c4-b4db-27c8d2aeb8dd.json create mode 100644 backend-node/data/mail-sent/eb92ed00-cc4f-4cc8-94c9-9bef312d16db.json create mode 100644 backend-node/data/mail-sent/fcea6149-a098-4212-aa00-baef0cc083d6.json create mode 100644 backend-node/data/mail-sent/fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082.json create mode 100644 frontend/app/(main)/admin/mail/bulk-send/page.tsx create mode 100644 frontend/app/(main)/admin/mail/drafts/page.tsx create mode 100644 frontend/app/(main)/admin/mail/trash/page.tsx create mode 100644 frontend/components/mail/MailNotifications.tsx 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/375f2326-ca86-468a-bfc3-2d4c3825577b.json b/backend-node/data/mail-sent/375f2326-ca86-468a-bfc3-2d4c3825577b.json new file mode 100644 index 00000000..dc1a0eb1 --- /dev/null +++ b/backend-node/data/mail-sent/375f2326-ca86-468a-bfc3-2d4c3825577b.json @@ -0,0 +1,18 @@ +{ + "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": [] +} \ 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..5191bb6a --- /dev/null +++ b/backend-node/data/mail-sent/386e334a-df76-440c-ae8a-9bf06982fdc8.json @@ -0,0 +1,15 @@ +{ + "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" +} \ 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..e5936003 --- /dev/null +++ b/backend-node/data/mail-sent/3d411dc4-69a6-4236-b878-9693dff881be.json @@ -0,0 +1,17 @@ +{ + "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" +} \ 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..a5809da2 --- /dev/null +++ b/backend-node/data/mail-sent/3e30a264-8431-44c7-96ef-eed551e66a11.json @@ -0,0 +1,15 @@ +{ + "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" +} \ 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..aecbe48e --- /dev/null +++ b/backend-node/data/mail-sent/5bfb2acd-023a-4865-a738-2900179db5fb.json @@ -0,0 +1,15 @@ +{ + "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" +} \ 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..6485eb1b --- /dev/null +++ b/backend-node/data/mail-sent/683c1323-1895-403a-bb9a-4e111a8909f6.json @@ -0,0 +1,17 @@ +{ + "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" +} \ 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..438aad96 --- /dev/null +++ b/backend-node/data/mail-sent/7bed27d5-dae4-4ba8-85d0-c474c4fb907a.json @@ -0,0 +1,15 @@ +{ + "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" +} \ 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..e3150d8f --- /dev/null +++ b/backend-node/data/mail-sent/8990ea86-3112-4e7c-b3e0-8b494181c4e0.json @@ -0,0 +1,12 @@ +{ + "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" +} \ 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..831d8414 --- /dev/null +++ b/backend-node/data/mail-sent/9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e.json @@ -0,0 +1,18 @@ +{ + "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": [] +} \ 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..ce2d258c --- /dev/null +++ b/backend-node/data/mail-sent/9d0b9fcf-cabf-4053-b6b6-6e110add22de.json @@ -0,0 +1,17 @@ +{ + "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" +} \ 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..dfc37164 --- /dev/null +++ b/backend-node/data/mail-sent/b293e530-2b2d-4b8a-8081-d103fab5a13f.json @@ -0,0 +1,17 @@ +{ + "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" +} \ 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..9c4ac60e --- /dev/null +++ b/backend-node/data/mail-sent/e3501abc-cd31-4b20-bb02-3c7ddbe54eb8.json @@ -0,0 +1,12 @@ +{ + "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" +} \ 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..e9f68940 --- /dev/null +++ b/backend-node/data/mail-sent/fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082.json @@ -0,0 +1,27 @@ +{ + "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": [] +} \ 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 0a22af73..979d191b 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -272,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..d80cff7e 100644 --- a/backend-node/src/controllers/mailReceiveBasicController.ts +++ b/backend-node/src/controllers/mailReceiveBasicController.ts @@ -217,5 +217,33 @@ 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 : '메일 삭제 실패', + }); + } + } } diff --git a/backend-node/src/controllers/mailSendSimpleController.ts b/backend-node/src/controllers/mailSendSimpleController.ts index de8610b7..a29be016 100644 --- a/backend-node/src/controllers/mailSendSimpleController.ts +++ b/backend-node/src/controllers/mailSendSimpleController.ts @@ -125,6 +125,54 @@ export class MailSendSimpleController { } } + /** + * 대량 메일 발송 + */ + async sendBulkMail(req: Request, res: Response) { + try { + const { accountId, templateId, subject, recipients } = req.body; + + // 필수 파라미터 검증 + if (!accountId || !templateId || !subject || !recipients || !Array.isArray(recipients)) { + 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, + 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 27b71c4d..5863eed9 100644 --- a/backend-node/src/routes/mailSentHistoryRoutes.ts +++ b/backend-node/src/routes/mailSentHistoryRoutes.ts @@ -13,10 +13,31 @@ router.get('/statistics', (req, res) => mailSentHistoryController.getStatistics( // GET /api/mail/sent - 발송 이력 목록 조회 router.get('/', (req, res) => mailSentHistoryController.getList(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..e3f2e278 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, }); } @@ -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(); }); } @@ -774,4 +802,94 @@ 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(); + }); + } } diff --git a/backend-node/src/services/mailSendSimpleService.ts b/backend-node/src/services/mailSendSimpleService.ts index 188e68c8..a5de90ef 100644 --- a/backend-node/src/services/mailSendSimpleService.ts +++ b/backend-node/src/services/mailSendSimpleService.ts @@ -34,6 +34,28 @@ export interface SendMailResult { error?: string; } +export interface BulkSendRequest { + accountId: string; + templateId: string; + subject: string; + 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 { /** * 단일 메일 발송 또는 소규모 발송 @@ -402,6 +424,72 @@ 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, + 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 연결 테스트 */ diff --git a/backend-node/src/services/mailSentHistoryService.ts b/backend-node/src/services/mailSentHistoryService.ts index c7828888..884c4d02 100644 --- a/backend-node/src/services/mailSentHistoryService.ts +++ b/backend-node/src/services/mailSentHistoryService.ts @@ -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..3213dafd 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 []; } } 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/bulk-send/page.tsx b/frontend/app/(main)/admin/mail/bulk-send/page.tsx new file mode 100644 index 00000000..bd5a00af --- /dev/null +++ b/frontend/app/(main)/admin/mail/bulk-send/page.tsx @@ -0,0 +1,481 @@ +"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 [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.isActive)); + } 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 (!selectedTemplateId) { + 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: selectedTemplateId, + 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 파일로 여러 수신자에게 메일을 발송하세요

+
+
+ + + +
+ +
+ {/* 왼쪽: 설정 */} +
+ {/* 계정 선택 */} + + + 발송 설정 + + +
+ + +
+ +
+ + +
+ +
+ + setSubject(e.target.value)} + placeholder="메일 제목을 입력하세요" + /> +
+
+
+ + {/* CSV 업로드 */} + + + 수신자 업로드 + + +
+ +
+ + +
+

+ 첫 번째 줄은 헤더(email, name, company 등)여야 합니다. +

+
+ + {csvFile && ( +
+
+ + {csvFile.name} +
+ +
+ )} + + {recipients.length > 0 && ( +
+
+ + {recipients.length}명의 수신자 +
+

+ 변수: {Object.keys(recipients[0]?.variables || {}).join(", ")} +

+
+ )} +
+
+
+ + {/* 오른쪽: 미리보기 & 발송 */} +
+ {/* 발송 버튼 */} + + + 발송 실행 + + + {sending && ( +
+
+ 발송 진행 중... + + {sendProgress.sent} / {sendProgress.total} + +
+
+
+
+
+ )} + + + +
+
+ +
+

주의사항

+
    +
  • 발송 속도는 계정 설정에 따라 제한됩니다
  • +
  • 대량 발송 시 스팸으로 분류될 수 있습니다
  • +
  • 발송 후 취소할 수 없습니다
  • +
+
+
+
+ + + + {/* 수신자 목록 미리보기 */} + {recipients.length > 0 && ( + + + 수신자 목록 미리보기 + + +
+ {recipients.slice(0, 10).map((recipient, index) => ( +
+
{recipient.email}
+
+ {Object.entries(recipient.variables).map(([key, value]) => ( + + {key}: {value} + + ))} +
+
+ ))} + {recipients.length > 10 && ( +

+ 외 {recipients.length - 10}명 +

+ )} +
+
+
+ )} +
+
+
+
+ ); +} + diff --git a/frontend/app/(main)/admin/mail/dashboard/page.tsx b/frontend/app/(main)/admin/mail/dashboard/page.tsx index dd58c5ed..2b6f1a63 100644 --- a/frontend/app/(main)/admin/mail/dashboard/page.tsx +++ b/frontend/app/(main)/admin/mail/dashboard/page.tsx @@ -13,9 +13,12 @@ import { TrendingUp, Users, Calendar, - ArrowRight + ArrowRight, + Trash2, + Edit } from "lucide-react"; import { getMailAccounts, getMailTemplates, getMailStatistics, getTodayReceivedCount } from "@/lib/api/mail"; +import MailNotifications from "@/components/mail/MailNotifications"; interface DashboardStats { totalAccounts: number; @@ -153,6 +156,13 @@ export default function MailDashboardPage() { icon: Send, color: "orange", }, + { + title: "대량 발송", + description: "CSV로 대량 발송", + href: "/admin/mail/bulk-send", + icon: Users, + color: "teal", + }, { title: "보낸메일함", description: "발송 이력 확인", @@ -167,11 +177,25 @@ export default function MailDashboardPage() { icon: Inbox, color: "purple", }, + { + title: "임시 저장", + description: "작성 중인 메일", + href: "/admin/mail/drafts", + icon: Edit, + color: "amber", + }, + { + title: "휴지통", + description: "삭제된 메일", + href: "/admin/mail/trash", + icon: Trash2, + color: "red", + }, ]; return (
-
+
{/* 페이지 제목 */}
@@ -183,19 +207,22 @@ export default function MailDashboardPage() {

메일 시스템의 전체 현황을 한눈에 확인하세요

- +
+ + +
{/* 통계 카드 */} -
+
{statCards.map((stat, index) => ( @@ -227,7 +254,7 @@ export default function MailDashboardPage() {
{/* 이번 달 통계 */} -
+
diff --git a/frontend/app/(main)/admin/mail/drafts/page.tsx b/frontend/app/(main)/admin/mail/drafts/page.tsx new file mode 100644 index 00000000..e5aa1de9 --- /dev/null +++ b/frontend/app/(main)/admin/mail/drafts/page.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { getSentMailList, updateDraft, deleteSentMail, bulkDeleteMails, type SentMailHistory } from "@/lib/api/mail"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Edit, Trash2, Loader2, Mail } from "lucide-react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; + +export default function DraftsPage() { + const router = useRouter(); + const [drafts, setDrafts] = useState([]); + const [loading, setLoading] = useState(true); + const [deleting, setDeleting] = useState(null); + const [selectedIds, setSelectedIds] = useState([]); + const [bulkDeleting, setBulkDeleting] = useState(false); + + useEffect(() => { + loadDrafts(); + }, []); + + const loadDrafts = async () => { + try { + setLoading(true); + const response = await getSentMailList({ + status: "draft", + sortBy: "updatedAt", + sortOrder: "desc", + }); + console.log('📋 임시 저장 목록 조회:', response); + console.log('📋 임시 저장 개수:', response.items.length); + setDrafts(response.items); + } catch (error) { + console.error("❌ 임시 저장 메일 로드 실패:", error); + } finally { + setLoading(false); + } + }; + + const handleEdit = (draft: SentMailHistory) => { + // 임시 저장 메일을 메일 발송 페이지로 전달 + const params = new URLSearchParams({ + draftId: draft.id, + to: draft.to.join(","), + cc: draft.cc?.join(",") || "", + bcc: draft.bcc?.join(",") || "", + subject: draft.subject, + content: draft.htmlContent, + accountId: draft.accountId, + }); + router.push(`/admin/mail/send?${params.toString()}`); + }; + + const handleDelete = async (id: string) => { + if (!confirm("이 임시 저장 메일을 삭제하시겠습니까?")) return; + + try { + setDeleting(id); + await deleteSentMail(id); + setDrafts(drafts.filter((d) => d.id !== id)); + setSelectedIds(selectedIds.filter((selectedId) => selectedId !== id)); + } catch (error) { + console.error("임시 저장 메일 삭제 실패:", error); + alert("삭제에 실패했습니다."); + } finally { + setDeleting(null); + } + }; + + const handleBulkDelete = async () => { + if (selectedIds.length === 0) { + alert("삭제할 메일을 선택해주세요."); + return; + } + + if (!confirm(`선택한 ${selectedIds.length}개의 임시 저장 메일을 삭제하시겠습니까?`)) return; + + try { + setBulkDeleting(true); + const result = await bulkDeleteMails(selectedIds); + setDrafts(drafts.filter((d) => !selectedIds.includes(d.id))); + setSelectedIds([]); + alert(result.message); + } catch (error) { + console.error("일괄 삭제 실패:", error); + alert("일괄 삭제에 실패했습니다."); + } finally { + setBulkDeleting(false); + } + }; + + const handleSelectAll = () => { + if (selectedIds.length === drafts.length) { + setSelectedIds([]); + } else { + setSelectedIds(drafts.map((d) => d.id)); + } + }; + + const handleSelectOne = (id: string) => { + if (selectedIds.includes(id)) { + setSelectedIds(selectedIds.filter((selectedId) => selectedId !== id)); + } else { + setSelectedIds([...selectedIds, id]); + } + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

임시보관함

+

작성 중인 메일이 자동으로 저장됩니다

+
+ + {drafts.length === 0 ? ( + + + +

임시 저장된 메일이 없습니다

+
+
+ ) : ( +
+ {drafts.map((draft) => ( + + +
+
+ + {draft.subject || "(제목 없음)"} + + + 받는 사람: {draft.to.join(", ") || "(없음)"} + +
+
+ + +
+
+
+ +
+ 계정: {draft.accountName || draft.accountEmail} + + {draft.updatedAt + ? format(new Date(draft.updatedAt), "yyyy-MM-dd HH:mm", { locale: ko }) + : format(new Date(draft.sentAt), "yyyy-MM-dd HH:mm", { locale: ko })} + +
+ {draft.htmlContent && ( +
]*>/g, "").substring(0, 100), + }} + /> + )} + + + ))} +
+ )} +
+ ); +} + diff --git a/frontend/app/(main)/admin/mail/receive/page.tsx b/frontend/app/(main)/admin/mail/receive/page.tsx index 1d3d8bf8..d9fe1dc6 100644 --- a/frontend/app/(main)/admin/mail/receive/page.tsx +++ b/frontend/app/(main)/admin/mail/receive/page.tsx @@ -16,21 +16,30 @@ import { SortAsc, SortDesc, ChevronRight, + Reply, + Forward, + Trash2, } from "lucide-react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; import { Separator } from "@/components/ui/separator"; import { MailAccount, ReceivedMail, + MailDetail, getMailAccounts, getReceivedMails, testImapConnection, + getMailDetail, + markMailAsRead, + downloadMailAttachment, } from "@/lib/api/mail"; -import MailDetailModal from "@/components/mail/MailDetailModal"; +import { apiClient } from "@/lib/api/client"; +import DOMPurify from "isomorphic-dompurify"; export default function MailReceivePage() { const router = useRouter(); + const searchParams = useSearchParams(); const [accounts, setAccounts] = useState([]); const [selectedAccountId, setSelectedAccountId] = useState(""); const [mails, setMails] = useState([]); @@ -41,9 +50,11 @@ export default function MailReceivePage() { message: string; } | null>(null); - // 메일 상세 모달 상태 - const [isDetailModalOpen, setIsDetailModalOpen] = useState(false); + // 메일 상세 상태 (모달 대신 패널) const [selectedMailId, setSelectedMailId] = useState(""); + const [selectedMailDetail, setSelectedMailDetail] = useState(null); + const [loadingDetail, setLoadingDetail] = useState(false); + const [deleting, setDeleting] = useState(false); // 검색 및 필터 상태 const [searchTerm, setSearchTerm] = useState(""); @@ -62,6 +73,30 @@ export default function MailReceivePage() { } }, [selectedAccountId]); + // URL 파라미터에서 mailId 읽기 및 자동 선택 + useEffect(() => { + const mailId = searchParams.get('mailId'); + const accountId = searchParams.get('accountId'); + + if (mailId && accountId) { + console.log('📧 URL에서 메일 ID 감지:', mailId, accountId); + setSelectedAccountId(accountId); + setSelectedMailId(mailId); + // 메일 상세 로드는 handleMailClick에서 처리됨 + } + }, [searchParams]); + + // 메일 목록 로드 후 URL에서 지정된 메일 자동 선택 + useEffect(() => { + if (selectedMailId && mails.length > 0 && !selectedMailDetail) { + const mail = mails.find(m => m.id === selectedMailId); + if (mail) { + console.log('🎯 URL에서 지정된 메일 자동 선택:', selectedMailId); + handleMailClick(mail); + } + } + }, [mails, selectedMailId, selectedMailDetail]); // selectedMailDetail 추가로 무한 루프 방지 + // 자동 새로고침 (30초마다) useEffect(() => { if (!selectedAccountId) return; @@ -95,7 +130,22 @@ export default function MailReceivePage() { setTestResult(null); try { const data = await getReceivedMails(selectedAccountId, 50); - setMails(data); + + // 현재 로컬에서 읽음 처리한 메일들의 상태를 유지 + setMails((prevMails) => { + const localReadMailIds = new Set( + prevMails.filter(m => m.isRead).map(m => m.id) + ); + + return data.map(mail => ({ + ...mail, + // 로컬에서 읽음 처리했거나 서버에서 읽음 상태면 읽음으로 표시 + isRead: mail.isRead || localReadMailIds.has(mail.id) + })); + }); + + // 알림 갱신 이벤트 발생 (새 메일이 있을 수 있음) + window.dispatchEvent(new CustomEvent('mail-received')); } catch (error) { console.error("메일 로드 실패:", error); alert( @@ -153,14 +203,94 @@ export default function MailReceivePage() { } }; - const handleMailClick = (mail: ReceivedMail) => { + const handleMailClick = async (mail: ReceivedMail) => { setSelectedMailId(mail.id); - setIsDetailModalOpen(true); + setLoadingDetail(true); + + // 즉시 로컬 상태 업데이트 (UI 반응성 향상) + console.log('📧 메일 클릭:', mail.id, '현재 읽음 상태:', mail.isRead); + setMails((prevMails) => + prevMails.map((m) => + m.id === mail.id ? { ...m, isRead: true } : m + ) + ); + + // 메일 상세 정보 로드 + try { + // mail.id에서 accountId와 seqno 추출: "account-{timestamp}-{seqno}" 형식 + const mailIdParts = mail.id.split('-'); + const accountId = `${mailIdParts[0]}-${mailIdParts[1]}`; // "account-1759310844272" + const seqno = parseInt(mailIdParts[2], 10); // 13 + + console.log('🔍 추출된 accountId:', accountId, 'seqno:', seqno, '원본 mailId:', mail.id); + + const detail = await getMailDetail(accountId, seqno); + setSelectedMailDetail(detail); + + // 읽음 처리 + if (!mail.isRead) { + await markMailAsRead(accountId, seqno); + console.log('✅ 읽음 처리 완료 - seqno:', seqno); + + // 서버 상태 동기화 (백그라운드) - IMAP 서버 반영 대기 + setTimeout(() => { + if (selectedAccountId) { + console.log('🔄 서버 상태 동기화 시작'); + loadMails(); + } + }, 2000); // 2초로 증가 + } + } catch (error) { + console.error('메일 상세 로드 실패:', error); + } finally { + setLoadingDetail(false); + } }; - const handleMailRead = () => { - // 메일을 읽었으므로 목록 새로고침 - loadMails(); + const handleDeleteMail = async () => { + if (!selectedMailId || !confirm("이 메일을 IMAP 서버에서 삭제하시겠습니까?\n(Gmail/Naver 휴지통으로 이동됩니다)\n\n⚠️ IMAP 연결에 시간이 걸릴 수 있습니다.")) return; + + try { + setDeleting(true); + + // mail.id에서 accountId와 seqno 추출: "account-{timestamp}-{seqno}" 형식 + const mailIdParts = selectedMailId.split('-'); + const accountId = `${mailIdParts[0]}-${mailIdParts[1]}`; // "account-1759310844272" + const seqno = parseInt(mailIdParts[2], 10); // 10 + + console.log(`🗑️ 메일 삭제 시도: accountId=${accountId}, seqno=${seqno}`); + + // IMAP 서버에서 메일 삭제 (타임아웃 40초) + const response = await apiClient.delete(`/mail/receive/${accountId}/${seqno}`, { + timeout: 40000, // 40초 타임아웃 + }); + + if (response.data.success) { + // 메일 목록에서 제거 + setMails(mails.filter((m) => m.id !== selectedMailId)); + + // 상세 패널 닫기 + setSelectedMailId(""); + setSelectedMailDetail(null); + + alert("메일이 삭제되었습니다."); + console.log("✅ 메일 삭제 완료"); + } + } catch (error: any) { + console.error("메일 삭제 실패:", error); + + let errorMessage = "메일 삭제에 실패했습니다."; + + if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) { + errorMessage = "IMAP 서버 연결 시간 초과\n네트워크 상태를 확인하거나 나중에 다시 시도해주세요."; + } else if (error.response?.data?.message) { + errorMessage = error.response.data.message; + } + + alert(errorMessage); + } finally { + setDeleting(false); + } }; // 필터링 및 정렬된 메일 목록 @@ -365,106 +495,318 @@ export default function MailReceivePage() { )} - {/* 메일 목록 */} - {loading ? ( - - - - 메일을 불러오는 중... - - - ) : filteredAndSortedMails.length === 0 ? ( - - - -

- {!selectedAccountId - ? "메일 계정을 선택하세요" - : searchTerm || filterStatus !== "all" - ? "검색 결과가 없습니다" - : "받은 메일이 없습니다"} -

- {selectedAccountId && ( - )} - IMAP 연결 테스트 - - )} -
-
- ) : ( - - - - - 받은 메일함 ({filteredAndSortedMails.length}/{mails.length}개) - - - -
- {filteredAndSortedMails.map((mail) => ( -
handleMailClick(mail)} - className={`p-4 hover:bg-background transition-colors cursor-pointer ${ - !mail.isRead ? "bg-blue-50/30" : "" - }`} - > -
- {/* 읽음 표시 */} -
- {!mail.isRead && ( -
- )} -
- - {/* 메일 내용 */} -
-
- - {mail.from} - -
- {mail.hasAttachments && ( - + + + ) : ( + + + + + 받은 메일함 ({filteredAndSortedMails.length}/{mails.length}개) + + + +
+ {filteredAndSortedMails.map((mail) => ( +
handleMailClick(mail)} + className={`p-4 hover:bg-background transition-colors cursor-pointer ${ + !mail.isRead ? "bg-blue-50/30" : "" + } ${selectedMailId === mail.id ? "bg-accent border-l-4 border-l-primary" : ""}`} + > +
+ {/* 읽음 표시 */} +
+ {!mail.isRead && ( +
)} - - {formatDate(mail.date)} - +
+ + {/* 메일 내용 */} +
+
+ + {mail.from} + +
+ {mail.hasAttachments && ( + + )} + + {formatDate(mail.date)} + +
+
+

+ {mail.subject} +

+

+ {mail.preview} +

-

- {mail.subject} -

-

- {mail.preview} -

+ ))} +
+
+
+ )} +
+ + {/* 오른쪽: 메일 상세 패널 */} +
+ {selectedMailDetail ? ( + + +
+ {selectedMailDetail.subject} + +
+
+
+ 보낸 사람: + {selectedMailDetail.from} +
+
+ 받는 사람: + {selectedMailDetail.to} +
+
+ 날짜: + {new Date(selectedMailDetail.date).toLocaleString("ko-KR")}
- ))} -
- - - )} + + {/* 답장/전달/삭제 버튼 */} +
+ + + +
+ + + {/* 첨부파일 */} + {selectedMailDetail.attachments && selectedMailDetail.attachments.length > 0 && ( +
+

첨부파일 ({selectedMailDetail.attachments.length}개)

+
+ {selectedMailDetail.attachments.map((att, index) => ( +
+ + {att.filename} + ({(att.size / 1024).toFixed(1)} KB) +
+ ))} +
+
+ )} + + {/* 메일 본문 */} + {selectedMailDetail.htmlBody ? ( +
+ ) : ( +
+ {selectedMailDetail.textBody} +
+ )} + + + ) : loadingDetail ? ( + + + + 메일을 불러오는 중... + + + ) : ( + + + +

+ 메일을 선택하면 내용이 표시됩니다 +

+
+
+ )} +
+
{/* 안내 정보 */} @@ -563,15 +905,6 @@ export default function MailReceivePage() {
- - {/* 메일 상세 모달 */} - setIsDetailModalOpen(false)} - accountId={selectedAccountId} - mailId={selectedMailId} - onMailRead={handleMailRead} - />
); } diff --git a/frontend/app/(main)/admin/mail/send/page.tsx b/frontend/app/(main)/admin/mail/send/page.tsx index cca5466d..7b1ddb61 100644 --- a/frontend/app/(main)/admin/mail/send/page.tsx +++ b/frontend/app/(main)/admin/mail/send/page.tsx @@ -31,7 +31,7 @@ import { Settings, ChevronRight, } from "lucide-react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; import { Separator } from "@/components/ui/separator"; import { @@ -42,11 +42,14 @@ import { sendMail, extractTemplateVariables, renderTemplateToHtml, + saveDraft, + updateDraft, } from "@/lib/api/mail"; import { useToast } from "@/hooks/use-toast"; export default function MailSendPage() { const router = useRouter(); + const searchParams = useSearchParams(); const { toast } = useToast(); const [accounts, setAccounts] = useState([]); const [templates, setTemplates] = useState([]); @@ -66,6 +69,7 @@ export default function MailSendPage() { const [customHtml, setCustomHtml] = useState(""); const [variables, setVariables] = useState>({}); const [showPreview, setShowPreview] = useState(false); + const [isEditingHtml, setIsEditingHtml] = useState(false); // HTML 편집 모드 // 템플릿 변수 const [templateVariables, setTemplateVariables] = useState([]); @@ -74,9 +78,113 @@ export default function MailSendPage() { const [attachments, setAttachments] = useState([]); const [isDragging, setIsDragging] = useState(false); + // 임시 저장 + const [draftId, setDraftId] = useState(null); + const [lastSaved, setLastSaved] = useState(null); + const [autoSaving, setAutoSaving] = useState(false); + useEffect(() => { loadData(); - }, []); + + // 답장/전달 데이터 처리 + const action = searchParams.get("action"); + const dataParam = searchParams.get("data"); + + if (action && dataParam) { + try { + const data = JSON.parse(decodeURIComponent(dataParam)); + + if (action === "reply") { + // 답장: 받는사람 자동 입력, 제목에 Re: 추가 + const fromEmail = data.originalFrom.match(/<(.+?)>/)?.[1] || data.originalFrom; + setTo([fromEmail]); + setSubject(data.originalSubject.startsWith("Re: ") + ? data.originalSubject + : `Re: ${data.originalSubject}` + ); + + // 원본 메일을 순수 텍스트로 추가 (사용자가 읽기 쉽게) + const originalMessage = ` + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +원본 메일: + +보낸사람: ${data.originalFrom} +날짜: ${new Date(data.originalDate).toLocaleString("ko-KR")} +제목: ${data.originalSubject} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +${data.originalBody}`; + + setCustomHtml(originalMessage); + + toast({ + title: '답장 작성', + description: '받는사람과 제목이 자동으로 입력되었습니다.', + }); + } else if (action === "forward") { + // 전달: 받는사람 비어있음, 제목에 Fwd: 추가 + setSubject(data.originalSubject.startsWith("Fwd: ") + ? data.originalSubject + : `Fwd: ${data.originalSubject}` + ); + + // 원본 메일을 순수 텍스트로 추가 (사용자가 읽기 쉽게) + const originalMessage = ` + + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +전달된 메일: + +보낸사람: ${data.originalFrom} +날짜: ${new Date(data.originalDate).toLocaleString("ko-KR")} +제목: ${data.originalSubject} +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +${data.originalBody}`; + + setCustomHtml(originalMessage); + + toast({ + title: '메일 전달', + description: '전달할 메일 내용이 입력되었습니다. 받는사람을 입력하세요.', + }); + } + + // URL에서 파라미터 제거 (깔끔하게) + router.replace("/admin/mail/send"); + } catch (error) { + console.error("답장/전달 데이터 파싱 실패:", error); + } + return; + } + + // 임시 저장 메일 불러오기 + const draftIdParam = searchParams.get("draftId"); + const toParam = searchParams.get("to"); + const ccParam = searchParams.get("cc"); + const bccParam = searchParams.get("bcc"); + const subjectParam = searchParams.get("subject"); + const contentParam = searchParams.get("content"); + const accountIdParam = searchParams.get("accountId"); + + if (draftIdParam) { + setDraftId(draftIdParam); + if (toParam) setTo(toParam.split(",").filter(Boolean)); + if (ccParam) setCc(ccParam.split(",").filter(Boolean)); + if (bccParam) setBcc(bccParam.split(",").filter(Boolean)); + if (subjectParam) setSubject(subjectParam); + if (contentParam) setCustomHtml(contentParam); + if (accountIdParam) setSelectedAccountId(accountIdParam); + + toast({ + title: '임시 저장 메일 불러오기', + description: '작성 중이던 메일을 불러왔습니다.', + }); + return; + } + }, [searchParams]); const loadData = async () => { try { @@ -85,8 +193,16 @@ export default function MailSendPage() { getMailAccounts(), getMailTemplates(), ]); - setAccounts(accountsData.filter((acc) => acc.status === "active")); + const activeAccounts = accountsData.filter((acc) => acc.status === "active"); + setAccounts(activeAccounts); setTemplates(templatesData); + + // 계정이 선택되지 않았고, 활성 계정이 있으면 첫 번째 계정 자동 선택 + if (!selectedAccountId && activeAccounts.length > 0) { + setSelectedAccountId(activeAccounts[0].id); + console.log('🔧 첫 번째 계정 자동 선택:', activeAccounts[0].email); + } + console.log('📦 데이터 로드 완료:', { accounts: accountsData.length, templates: templatesData.length, @@ -109,6 +225,55 @@ export default function MailSendPage() { } }; + // 임시 저장 함수 + const handleAutoSave = async () => { + if (!selectedAccountId || (!subject && !customHtml && to.length === 0)) { + return; // 저장할 내용이 없으면 스킵 + } + + try { + setAutoSaving(true); + + const draftData = { + accountId: selectedAccountId, + accountName: accounts.find(a => a.id === selectedAccountId)?.name || "", + accountEmail: accounts.find(a => a.id === selectedAccountId)?.email || "", + to, + cc, + bcc, + subject, + htmlContent: customHtml, + templateId: selectedTemplateId || undefined, + }; + + if (draftId) { + // 기존 임시 저장 업데이트 + await updateDraft(draftId, draftData); + } else { + // 새로운 임시 저장 + const savedDraft = await saveDraft(draftData); + if (savedDraft && savedDraft.id) { + setDraftId(savedDraft.id); + } + } + + setLastSaved(new Date()); + } catch (error) { + console.error('임시 저장 실패:', error); + } finally { + setAutoSaving(false); + } + }; + + // 30초마다 자동 저장 + useEffect(() => { + const interval = setInterval(() => { + handleAutoSave(); + }, 30000); // 30초 + + return () => clearInterval(interval); + }, [selectedAccountId, to, cc, bcc, subject, customHtml, selectedTemplateId, draftId]); + // 템플릿 선택 시 (원본 다시 로드) const handleTemplateChange = async (templateId: string) => { console.log('🔄 템플릿 선택됨:', templateId); @@ -228,7 +393,7 @@ export default function MailSendPage() { .join(''); return ` -
+
${html}
`; @@ -275,8 +440,12 @@ export default function MailSendPage() { try { setSending(true); - // 텍스트를 HTML로 자동 변환 - const htmlContent = customHtml ? convertTextToHtml(customHtml) : undefined; + // HTML 변환 + let htmlContent = undefined; + if (customHtml.trim()) { + // 일반 텍스트를 HTML로 변환 + htmlContent = convertTextToHtml(customHtml); + } // FormData 생성 (파일 첨부 지원) const formData = new FormData(); @@ -354,6 +523,9 @@ export default function MailSendPage() { className: "border-green-500 bg-green-50", }); + // 알림 갱신 이벤트 발생 + window.dispatchEvent(new CustomEvent('mail-sent')); + // 폼 초기화 setTo([]); setCc([]); @@ -383,6 +555,58 @@ export default function MailSendPage() { } }; + // 임시 저장 + const handleSaveDraft = async () => { + try { + setAutoSaving(true); + + const account = accounts.find(a => a.id === selectedAccountId); + const draftData = { + accountId: selectedAccountId, + accountName: account?.name || "", + accountEmail: account?.email || "", + to, + cc, + bcc, + subject, + htmlContent: customHtml, + templateId: selectedTemplateId || undefined, + }; + + console.log('💾 임시 저장 데이터:', draftData); + + if (draftId) { + // 기존 임시 저장 업데이트 + await updateDraft(draftId, draftData); + console.log('✏️ 임시 저장 업데이트 완료:', draftId); + } else { + // 새로운 임시 저장 + const savedDraft = await saveDraft(draftData); + console.log('💾 임시 저장 완료:', savedDraft); + if (savedDraft && savedDraft.id) { + setDraftId(savedDraft.id); + } + } + + setLastSaved(new Date()); + + toast({ + title: "임시 저장 완료", + description: "작성 중인 메일이 저장되었습니다.", + }); + } catch (error: unknown) { + const err = error as Error; + console.error('❌ 임시 저장 실패:', err); + toast({ + title: "임시 저장 실패", + description: err.message || "임시 저장 중 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setAutoSaving(false); + } + }; + // 파일 첨부 관련 함수 const handleFileSelect = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); @@ -531,9 +755,72 @@ export default function MailSendPage() { {/* 제목 */} -
-

메일 발송

-

템플릿을 선택하거나 직접 작성하여 메일을 발송하세요

+
+
+

+ {subject.startsWith("Re: ") ? "답장 작성" : subject.startsWith("Fwd: ") ? "메일 전달" : "메일 발송"} +

+

+ {subject.startsWith("Re: ") + ? "받은 메일에 답장을 작성합니다" + : subject.startsWith("Fwd: ") + ? "메일을 다른 사람에게 전달합니다" + : "템플릿을 선택하거나 직접 작성하여 메일을 발송하세요"} +

+
+ +
+ {/* 임시 저장 표시 */} + {lastSaved && ( +
+ {autoSaving ? ( + <> + + 저장 중... + + ) : ( + <> + + + {new Date(lastSaved).toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + })} 임시 저장됨 + + + )} +
+ )} + + {/* 임시 저장 버튼 */} + + + {/* 임시 저장 목록 버튼 */} + + + +
@@ -957,30 +1244,41 @@ export default function MailSendPage() { return null; })()} - {/* 메일 내용 입력 - 항상 표시 */} -
- -